commit 4e6b3eca6f1cb5953a9b76565755d17485e8da4d Author: mamad Date: Thu Aug 27 19:53:30 2026 +0330 feat(core): initialize project structure, postgres schema and docker setup diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..327ef54 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +.env +*.session +*.session-journal +__pycache__/ +*.pyc +data/ +tests/ +.git/ +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6bb1e35 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Telegram Userbot Credentials (https://my.telegram.org) +API_ID= +API_HASH= +PHONE= + +# Telegram Admin Bot Credentials (@BotFather) +BOT_TOKEN= +ADMIN_USER_IDS=78649634 +REVIEW_CHANNEL_ID= + +# AI Provider Configuration +AI_PROVIDER=gemini +AI_API_KEY= +AI_MODEL=gemini-1.5-flash + +# PostgreSQL Configuration +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=copykar +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/copykar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ffee73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +.env +*.session +*.session-journal +__pycache__/ +*.pyc +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79c4bba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +# Prevent Python from writing .pyc files & enable unbuffered logs +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source code +COPY . . + +CMD ["python", "main.py"] diff --git a/core/metrics.py b/core/metrics.py new file mode 100644 index 0000000..5552067 --- /dev/null +++ b/core/metrics.py @@ -0,0 +1,56 @@ +from prometheus_client import Counter, Histogram, Gauge, start_http_server +import logging + +logger = logging.getLogger(__name__) + +# Counters +COLLECTED_POSTS_TOTAL = Counter( + "copykar_posts_collected_total", + "Total posts collected by the Telethon Userbot", + ["source_channel_id"] +) + +AI_REQUESTS_TOTAL = Counter( + "copykar_ai_requests_total", + "Total AI API calls made", + ["action", "status"] +) + +DUPLICATES_DETECTED_TOTAL = Counter( + "copykar_duplicates_detected_total", + "Total duplicate posts detected", + ["method"] # "hash" or "ai_semantic" +) + +ADMIN_ACTIONS_TOTAL = Counter( + "copykar_admin_actions_total", + "Total review decisions by admins", + ["action"] # "approved", "rejected", "routed" +) + +POSTS_PUBLISHED_TOTAL = Counter( + "copykar_posts_published_total", + "Total posts successfully published to target channels", + ["target_channel_id"] +) + +# Histograms +AI_LATENCY_SECONDS = Histogram( + "copykar_ai_latency_seconds", + "Time taken for AI API operations", + ["action"] +) + +# Gauges +QUEUE_POSTS_GAUGE = Gauge( + "copykar_posts_queue_gauge", + "Number of posts currently in various queue states", + ["status"] +) + +def start_metrics_server(port: int = 8000): + try: + start_http_server(port) + logger.info(f"Prometheus metrics server running on port {port}") + except Exception as e: + logger.error(f"Failed to start Prometheus metrics server: {e}") diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..1d12add --- /dev/null +++ b/db/__init__.py @@ -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"] diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..f1c3ba6 --- /dev/null +++ b/db/database.py @@ -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) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..f4c5046 --- /dev/null +++ b/db/models.py @@ -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 diff --git a/db/repository.py b/db/repository.py new file mode 100644 index 0000000..e3b5f93 --- /dev/null +++ b/db/repository.py @@ -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, + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28b8ba5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + postgres: + image: postgres:16-alpine + container_name: copykar_postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-copykar} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-copykar}"] + interval: 5s + timeout: 5s + retries: 5 + + copykar: + build: + context: . + dockerfile: Dockerfile + container_name: copykar_app + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./data:/app/data + - ./sessions:/app/sessions + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: copykar_prometheus + restart: unless-stopped + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:11.2.0 + container_name: copykar_grafana + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + ports: + - "3000:3000" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - grafana_data:/var/lib/grafana + depends_on: + - prometheus + +volumes: + postgres_data: + prometheus_data: + grafana_data: diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..7c03472 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Copykar Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..bb009bb --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..a2c9bfb --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: "copykar" + static_configs: + - targets: ["copykar:8000"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7ee60cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +telethon==1.44.0 +python-dotenv==1.2.3 +asyncpg==0.31.0 +prometheus-client==0.26.0 +httpx==0.28.1 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..3b510e7 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,63 @@ +import asyncio +import os +import tempfile +from db.database import init_db +from db.repository import Repository + +async def run_tests(): + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + db_path = tmp.name + print(f"Testing DB on {db_path}...") + await init_db(db_path) + repo = Repository(db_path) + + # 1. Test Sources + s_id = await repo.add_source(-1001234567890, "Source Tech", "source_tech") + sources = await repo.get_active_sources() + assert len(sources) == 1 + assert sources[0].channel_id == -1001234567890 + + # 2. Test Targets + t_id = await repo.add_target(-1009876543210, "Target Channel", "target_chan", post_interval_min=15) + targets = await repo.get_active_targets() + assert len(targets) == 1 + assert targets[0].post_interval_min == 15 + + # 3. Test Raw Post & Deduplication + post_id = await repo.create_raw_post( + source_channel_id=-1001234567890, + source_message_id=101, + raw_text="Breaking news: AI update released!", + content_hash="hash_12345", + is_duplicate=False + ) + assert post_id is not None + + # Check duplicate lookup + dup_match = await repo.find_duplicate_post("hash_12345") + assert dup_match is not None + assert dup_match.id == post_id + + # 4. Test AI update & Approval flow + await repo.update_ai_result(post_id, subject="AI/Tech", ai_text="Rewritten: AI update is now live!", suggested_target_id=t_id) + pending_review = await repo.get_posts_by_status("pending_review") + assert len(pending_review) == 1 + assert pending_review[0].subject == "AI/Tech" + + await repo.approve_post(post_id, target_channel_id=t_id) + approved_post = await repo.get_next_approved_post_for_target(t_id) + assert approved_post is not None + assert approved_post.id == post_id + + await repo.mark_post_published(post_id) + assert await repo.get_next_approved_post_for_target(t_id) is None + + # 5. Settings + await repo.set_setting("system_prompt", "You are a professional editor.") + val = await repo.get_setting("system_prompt") + assert val == "You are a professional editor." + + print("All database tests passed successfully!") + +if __name__ == "__main__": + asyncio.run(run_tests())