feat(core): initialize project structure, postgres schema and docker setup

This commit is contained in:
mamad
2026-08-27 19:53:30 +03:30
commit 4e6b3eca6f
15 changed files with 701 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
from db.database import init_db, get_db_connection
from db.models import SourceChannel, TargetChannel, Post, Setting
from db.repository import Repository
__all__ = ["init_db", "get_db_connection", "SourceChannel", "TargetChannel", "Post", "Setting", "Repository"]
+85
View File
@@ -0,0 +1,85 @@
import asyncpg
import os
from typing import Optional
DATABASE_URL = os.getenv(
"DATABASE_URL",
f"postgresql://{os.getenv('POSTGRES_USER', 'postgres')}:{os.getenv('POSTGRES_PASSWORD', 'postgres')}@{os.getenv('POSTGRES_HOST', 'localhost')}:{os.getenv('POSTGRES_PORT', '5432')}/{os.getenv('POSTGRES_DB', 'copykar')}"
)
SCHEMA = """
CREATE TABLE IF NOT EXISTS sources (
id SERIAL PRIMARY KEY,
channel_id BIGINT UNIQUE NOT NULL,
username VARCHAR(255),
title VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS targets (
id SERIAL PRIMARY KEY,
channel_id BIGINT UNIQUE NOT NULL,
title VARCHAR(255),
username VARCHAR(255),
post_interval_min INT DEFAULT 30,
last_post_time TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
source_channel_id BIGINT NOT NULL,
source_message_id BIGINT NOT NULL,
raw_text TEXT,
media_path TEXT,
media_type VARCHAR(64),
content_hash VARCHAR(128),
tags TEXT[] DEFAULT '{}',
is_duplicate BOOLEAN DEFAULT FALSE,
duplicate_of_id BIGINT REFERENCES posts(id) ON DELETE SET NULL,
similarity_reason TEXT,
subject VARCHAR(255),
ai_text TEXT,
suggested_target_id INT REFERENCES targets(id) ON DELETE SET NULL,
target_channel_id INT REFERENCES targets(id) ON DELETE SET NULL,
status VARCHAR(32) DEFAULT 'pending_ai',
review_message_id BIGINT,
scheduled_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_source_message UNIQUE (source_channel_id, source_message_id)
);
CREATE INDEX IF NOT EXISTS idx_posts_content_hash ON posts(content_hash);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_target_status ON posts(target_channel_id, status);
CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(128) PRIMARY KEY,
value TEXT NOT NULL,
description TEXT
);
"""
_pool: Optional[asyncpg.Pool] = None
async def get_db_pool(dsn: str = DATABASE_URL) -> asyncpg.Pool:
global _pool
if _pool is None or _pool._closed:
_pool = await asyncpg.create_pool(dsn=dsn, min_size=2, max_size=10)
return _pool
async def close_db_pool():
global _pool
if _pool is not None and not _pool._closed:
await _pool.close()
_pool = None
async def init_db(dsn: str = DATABASE_URL):
pool = await get_db_pool(dsn)
async with pool.acquire() as conn:
await conn.execute(SCHEMA)
+51
View File
@@ -0,0 +1,51 @@
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class SourceChannel:
id: Optional[int]
channel_id: int
username: Optional[str]
title: Optional[str]
is_active: bool = True
created_at: Optional[str] = None
@dataclass
class TargetChannel:
id: Optional[int]
channel_id: int
title: Optional[str]
username: Optional[str]
post_interval_min: int = 30
last_post_time: Optional[str] = None
is_active: bool = True
created_at: Optional[str] = None
@dataclass
class Post:
id: Optional[int]
source_channel_id: int
source_message_id: int
raw_text: Optional[str]
media_path: Optional[str] = None
media_type: Optional[str] = None
content_hash: Optional[str] = None
tags: List[str] = field(default_factory=list)
is_duplicate: bool = False
duplicate_of_id: Optional[int] = None
similarity_reason: Optional[str] = None
subject: Optional[str] = None
ai_text: Optional[str] = None
suggested_target_id: Optional[int] = None
target_channel_id: Optional[int] = None
status: str = "pending_ai" # pending_ai, pending_review, approved, scheduled, published, rejected
review_message_id: Optional[int] = None
scheduled_at: Optional[str] = None
published_at: Optional[str] = None
created_at: Optional[str] = None
@dataclass
class Setting:
key: str
value: str
description: Optional[str] = None
+281
View File
@@ -0,0 +1,281 @@
import asyncpg
from typing import List, Optional
from db.models import SourceChannel, TargetChannel, Post, Setting
from db.database import get_db_pool
class Repository:
def __init__(self, dsn: Optional[str] = None):
self.dsn = dsn
async def _get_pool(self) -> asyncpg.Pool:
if self.dsn:
return await get_db_pool(self.dsn)
return await get_db_pool()
# --- Source Channels ---
async def add_source(self, channel_id: int, title: Optional[str] = None, username: Optional[str] = None) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO sources (channel_id, title, username)
VALUES ($1, $2, $3)
ON CONFLICT(channel_id) DO UPDATE SET
title = EXCLUDED.title,
username = EXCLUDED.username,
is_active = TRUE
RETURNING id;
""",
channel_id, title, username,
)
return row["id"]
async def get_active_sources(self) -> List[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE;")
return [SourceChannel(**dict(r)) for r in rows]
async def get_source_by_channel_id(self, channel_id: int) -> Optional[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM sources WHERE channel_id = $1;", channel_id)
return SourceChannel(**dict(row)) if row else None
# --- Target Channels ---
async def add_target(self, channel_id: int, title: Optional[str] = None, username: Optional[str] = None, post_interval_min: int = 30) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO targets (channel_id, title, username, post_interval_min)
VALUES ($1, $2, $3, $4)
ON CONFLICT(channel_id) DO UPDATE SET
title = EXCLUDED.title,
username = EXCLUDED.username,
post_interval_min = EXCLUDED.post_interval_min,
is_active = TRUE
RETURNING id;
""",
channel_id, title, username, post_interval_min,
)
return row["id"]
async def get_active_targets(self) -> List[TargetChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE;")
return [TargetChannel(**dict(r)) for r in rows]
async def get_target_by_id(self, target_id: int) -> Optional[TargetChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM targets WHERE id = $1;", target_id)
return TargetChannel(**dict(row)) if row else None
async def update_target_last_post(self, target_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET last_post_time = CURRENT_TIMESTAMP WHERE id = $1;",
target_id,
)
# --- Posts & Deduplication ---
async def find_duplicate_post_by_hash(self, content_hash: str) -> Optional[Post]:
if not content_hash:
return None
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM posts WHERE content_hash = $1 ORDER BY id ASC LIMIT 1;",
content_hash,
)
return Post(**dict(row)) if row else None
async def find_candidate_posts_by_tags(self, tags: List[str], exclude_post_id: Optional[int] = None, hours_lookback: int = 72, limit: int = 5) -> List[Post]:
if not tags:
return []
pool = await self._get_pool()
async with pool.acquire() as conn:
query = """
SELECT * FROM posts
WHERE tags && $1::text[]
AND created_at >= NOW() - ($2 || ' hours')::interval
AND ($3::bigint IS NULL OR id != $3::bigint)
ORDER BY created_at DESC
LIMIT $4;
"""
rows = await conn.fetch(query, tags, str(hours_lookback), exclude_post_id, limit)
return [Post(**dict(r)) for r in rows]
async def create_raw_post(
self,
source_channel_id: int,
source_message_id: int,
raw_text: Optional[str],
media_path: Optional[str] = None,
media_type: Optional[str] = None,
content_hash: Optional[str] = None,
is_duplicate: bool = False,
duplicate_of_id: Optional[int] = None,
similarity_reason: Optional[str] = None,
) -> Optional[int]:
pool = await self._get_pool()
async with pool.acquire() as conn:
try:
row = await conn.fetchrow(
"""
INSERT INTO posts (
source_channel_id, source_message_id, raw_text, media_path,
media_type, content_hash, is_duplicate, duplicate_of_id, similarity_reason, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending_ai')
RETURNING id;
""",
source_channel_id,
source_message_id,
raw_text,
media_path,
media_type,
content_hash,
is_duplicate,
duplicate_of_id,
similarity_reason,
)
return row["id"] if row else None
except asyncpg.UniqueViolationError:
return None
async def get_posts_by_status(self, status: str, limit: int = 20) -> List[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM posts WHERE status = $1 ORDER BY id ASC LIMIT $2;",
status, limit,
)
return [Post(**dict(r)) for r in rows]
async def get_post_by_id(self, post_id: int) -> Optional[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
return Post(**dict(row)) if row else None
async def update_post_tags(self, post_id: int, tags: List[str], subject: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE posts SET tags = $1, subject = $2 WHERE id = $3;",
tags, subject, post_id,
)
async def update_post_duplicate_status(
self,
post_id: int,
is_duplicate: bool,
duplicate_of_id: Optional[int] = None,
similarity_reason: Optional[str] = None,
) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE posts
SET is_duplicate = $1, duplicate_of_id = $2, similarity_reason = $3
WHERE id = $4;
""",
is_duplicate, duplicate_of_id, similarity_reason, post_id,
)
async def update_ai_result(
self,
post_id: int,
subject: str,
ai_text: str,
tags: List[str],
suggested_target_id: Optional[int] = None,
is_duplicate: bool = False,
duplicate_of_id: Optional[int] = None,
similarity_reason: Optional[str] = None,
) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE posts
SET subject = $1, ai_text = $2, tags = $3, suggested_target_id = $4,
is_duplicate = $5, duplicate_of_id = $6, similarity_reason = $7,
status = 'pending_review'
WHERE id = $8;
""",
subject, ai_text, tags, suggested_target_id, is_duplicate, duplicate_of_id, similarity_reason, post_id,
)
async def update_review_message_id(self, post_id: int, review_message_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE posts SET review_message_id = $1 WHERE id = $2;",
review_message_id, post_id,
)
async def approve_post(self, post_id: int, target_channel_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE posts
SET target_channel_id = $1, status = 'approved'
WHERE id = $2;
""",
target_channel_id, post_id,
)
async def reject_post(self, post_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE posts SET status = 'rejected' WHERE id = $1;", post_id)
async def mark_post_published(self, post_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE posts SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE id = $1;",
post_id,
)
async def get_next_approved_post_for_target(self, target_id: int) -> Optional[Post]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT * FROM posts
WHERE target_channel_id = $1 AND status = 'approved'
ORDER BY id ASC
LIMIT 1;
""",
target_id,
)
return Post(**dict(row)) if row else None
# --- Settings ---
async def get_setting(self, key: str, default: Optional[str] = None) -> Optional[str]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value FROM settings WHERE key = $1;", key)
return row["value"] if row else default
async def set_setting(self, key: str, value: str, description: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO settings (key, value, description)
VALUES ($1, $2, $3)
ON CONFLICT(key) DO UPDATE SET
value = EXCLUDED.value,
description = COALESCE(EXCLUDED.description, settings.description);
""",
key, value, description,
)