db: extend schema for categories, channel profiles, and error logs

This commit is contained in:
mamad
2026-08-28 19:33:15 +03:30
parent 8eb5ca0a3f
commit db2e726a57
5 changed files with 889 additions and 23 deletions
+571 -23
View File
@@ -1,9 +1,11 @@
import json
import asyncpg
from typing import List, Optional, Dict, Any
from db.models import SourceChannel, TargetChannel, Post, Setting
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Tuple
from db.models import SourceChannel, TargetChannel, Post, Setting, AILog, AIProviderProfile, ChannelCategory
from db.database import get_db_pool
def _parse_post_row(row: asyncpg.Record) -> Post:
data = dict(row)
if isinstance(data.get("published_to"), str):
@@ -48,6 +50,12 @@ class Repository:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE ORDER BY id ASC;")
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
async def get_source_by_id(self, source_id: int) -> Optional[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
@@ -113,6 +121,23 @@ class Repository:
custom_footer, target_id
)
async def update_target_language(self, target_id: int, language: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET language = $1 WHERE id = $2;",
language, target_id
)
async def update_target_custom_prompt(self, target_id: int, custom_prompt: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET custom_prompt = $1 WHERE id = $2;",
custom_prompt, target_id
)
async def update_target_schedule(
self,
target_id: int,
@@ -152,6 +177,43 @@ class Repository:
row = await conn.fetchrow("SELECT * FROM targets WHERE id = $1;", target_id)
return TargetChannel(**dict(row)) if row else None
async def set_target_auto_sources(self, target_id: int, source_channel_ids: List[int]) -> None:
"""Replace the set of source channels auto-routed into this target."""
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE targets SET auto_source_ids = $1::bigint[] WHERE id = $2;",
sorted(set(source_channel_ids)), target_id,
)
async def toggle_target_auto_source(self, target_id: int, source_channel_id: int) -> bool:
"""Add or remove one source from a target's auto-route list. Returns the new state."""
target = await self.get_target_by_id(target_id)
if not target:
return False
current = set(target.auto_source_ids or [])
enabled = source_channel_id not in current
if enabled:
current.add(source_channel_id)
else:
current.discard(source_channel_id)
await self.set_target_auto_sources(target_id, list(current))
return enabled
async def get_targets_auto_routed_from(self, source_channel_id: int) -> List[TargetChannel]:
"""Active targets that have subscribed to this source channel."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM targets
WHERE is_active = TRUE AND $1 = ANY(auto_source_ids)
ORDER BY id ASC;
""",
source_channel_id,
)
return [TargetChannel(**dict(r)) for r in rows]
async def update_target_last_post(self, target_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
@@ -205,6 +267,38 @@ class Repository:
row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
return _parse_post_row(row) if row else None
async def find_duplicate_post(self, content_hash: Optional[str], exclude_post_id: Optional[int] = None) -> Optional[Post]:
"""Return the earliest post already carrying this content hash, if any."""
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
AND is_deleted = FALSE
AND ($2::bigint IS NULL OR id <> $2)
ORDER BY id ASC
LIMIT 1;
""",
content_hash, exclude_post_id,
)
return _parse_post_row(row) if row else None
async def count_posts_by_status(self, status: str) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval("SELECT COUNT(*) FROM posts WHERE status = $1;", status) or 0
async def count_posts_from_source(self, source_channel_id: int) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"SELECT COUNT(*) FROM posts WHERE source_channel_id = $1 AND is_deleted = FALSE;",
source_channel_id,
) or 0
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:
@@ -214,6 +308,23 @@ class Repository:
)
return [_parse_post_row(r) for r in rows]
async def get_unreviewed_posts(self, limit: int = 50) -> List[Post]:
"""Pending posts that never made it onto a review card in the admin channel."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM posts
WHERE status = 'pending_review'
AND is_deleted = FALSE
AND review_message_id IS NULL
ORDER BY id ASC
LIMIT $1;
""",
limit,
)
return [_parse_post_row(r) for r in rows]
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:
@@ -222,32 +333,469 @@ class Repository:
review_message_id, post_id,
)
async def record_post_published_to_target(self, post_id: int, target_id: int, target_title: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
record_item = json.dumps({
"target_id": target_id,
"target_title": target_title,
"published_at": str(asyncpg.types.Type)
})
await conn.execute(
"""
UPDATE posts
SET published_to = published_to || $1::jsonb,
status = 'published',
published_at = CURRENT_TIMESTAMP
WHERE id = $2;
""",
f'[{{"target_id": {target_id}, "target_title": "{target_title}", "published_at": "{asyncpg.types.Type}"}}]',
post_id,
)
async def _upsert_published_entry(
self,
post_id: int,
target_id: int,
target_title: str,
published_at: Optional[datetime],
mark_published: bool,
) -> None:
"""Add or stamp this target's entry in posts.published_to.
async def reject_post(self, post_id: int) -> None:
The list is read, edited and written back as a single parameterised jsonb value so
that titles containing quotes or backslashes cannot corrupt the document.
"""
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 with conn.transaction():
row = await conn.fetchrow("SELECT published_to FROM posts WHERE id = $1 FOR UPDATE;", post_id)
if not row:
return
entries = row["published_to"]
if isinstance(entries, str):
try:
entries = json.loads(entries)
except Exception:
entries = []
if not isinstance(entries, list):
entries = []
stamp = published_at.isoformat() if published_at else None
for entry in entries:
if isinstance(entry, dict) and entry.get("target_id") == target_id:
entry["target_title"] = target_title
if stamp:
entry["published_at"] = stamp
break
else:
entries.append({
"target_id": target_id,
"target_title": target_title,
"published_at": stamp,
})
if mark_published:
await conn.execute(
"""
UPDATE posts
SET published_to = $1::jsonb,
status = 'published',
published_at = COALESCE(published_at, CURRENT_TIMESTAMP)
WHERE id = $2;
""",
json.dumps(entries, ensure_ascii=False), post_id,
)
else:
await conn.execute(
"UPDATE posts SET published_to = $1::jsonb WHERE id = $2;",
json.dumps(entries, ensure_ascii=False), post_id,
)
async def record_post_queued_to_target(self, post_id: int, target_id: int, target_title: str) -> None:
"""Note that a post is waiting in a target's delivery queue. Status is left untouched."""
await self._upsert_published_entry(post_id, target_id, target_title, published_at=None, mark_published=False)
async def record_post_published_to_target(self, post_id: int, target_id: int, target_title: str) -> None:
"""Stamp the post as actually delivered to a target channel."""
await self._upsert_published_entry(
post_id, target_id, target_title,
published_at=datetime.now(timezone.utc), mark_published=True,
)
# --- Error tracking ---
async def get_open_error_summary(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Unresolved errors grouped by service + exception type, newest group first."""
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT service_name, error_type, COUNT(*) AS occurrences,
MAX(created_at) AS last_seen,
(ARRAY_AGG(error_message ORDER BY created_at DESC))[1] AS last_message
FROM error_logs
WHERE resolved = FALSE
GROUP BY service_name, error_type
ORDER BY MAX(created_at) DESC
LIMIT $1;
""",
limit,
)
return [dict(r) for r in rows]
async def count_open_errors(self) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval("SELECT COUNT(*) FROM error_logs WHERE resolved = FALSE;") or 0
async def resolve_errors(
self,
service_name: Optional[str] = None,
error_type: Optional[str] = None,
note: Optional[str] = None,
) -> int:
"""Mark matching unresolved errors as fixed. Both filters None resolves everything."""
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"""
WITH updated AS (
UPDATE error_logs
SET resolved = TRUE,
resolved_at = CURRENT_TIMESTAMP,
resolved_note = COALESCE($3, resolved_note)
WHERE resolved = FALSE
AND ($1::text IS NULL OR service_name = $1)
AND ($2::text IS NULL OR error_type = $2)
RETURNING 1
)
SELECT COUNT(*) FROM updated;
""",
service_name, error_type, note,
) or 0
async def reject_post(self, post_id: int, rejection_reason: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
if rejection_reason:
await conn.execute(
"UPDATE posts SET status = 'rejected', rejection_reason = $1 WHERE id = $2;",
rejection_reason, post_id
)
else:
await conn.execute("UPDATE posts SET status = 'rejected' WHERE id = $1;", post_id)
async def soft_delete_post(self, post_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE posts SET is_deleted = TRUE, status = 'deleted' WHERE id = $1;", post_id)
# --- System 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:
val = await conn.fetchval("SELECT value FROM settings WHERE key = $1;", key)
return val if val is not None 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
)
async def get_all_settings(self) -> Dict[str, str]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT key, value FROM settings;")
return {r["key"]: r["value"] for r in rows}
# --- AI Logs ---
async def record_ai_log(
self,
action_name: str,
provider: str,
model: str,
prompt: str,
system_prompt: Optional[str] = None,
response_text: Optional[str] = None,
duration_sec: float = 0.0,
status: str = "success",
error_message: Optional[str] = None,
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
return await conn.fetchval(
"""
INSERT INTO ai_logs (action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id;
""",
action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message
)
async def get_recent_ai_logs(self, limit: int = 10) -> List[AILog]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message,
to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_logs
ORDER BY id DESC
LIMIT $1;
""",
limit
)
return [AILog(**dict(r)) for r in rows]
async def get_ai_log_by_id(self, log_id: int) -> Optional[AILog]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, action_name, provider, model, prompt, system_prompt, response_text, duration_sec, status, error_message,
to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_logs
WHERE id = $1;
""",
log_id
)
return AILog(**dict(row)) if row else None
# --- AI Providers Management ---
async def ensure_default_providers(self) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("SELECT COUNT(*) FROM ai_providers;")
if count == 0:
await conn.execute(
"""
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active)
VALUES
('AGY (سرور داخلی)', 'agy', 'antigravity', 'http://host.docker.internal:8088/v1', '', '', TRUE),
('OpenRouter / OpenAI', 'openai', 'google/gemini-3.5-flash', 'https://openrouter.ai/api/v1', '', '', FALSE),
('Google Gemini Direct', 'gemini', 'gemini-1.5-flash', 'https://generativelanguage.googleapis.com/v1beta', '', '', FALSE);
"""
)
async def add_provider_profile(
self,
name: str,
provider_type: str,
model: str,
base_url: str = "",
api_key: str = "",
reasoning_effort: str = "",
is_active: bool = False
) -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
if is_active:
await conn.execute("UPDATE ai_providers SET is_active = FALSE;")
return await conn.fetchval(
"""
INSERT INTO ai_providers (name, provider_type, model, base_url, api_key, reasoning_effort, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id;
""",
name, provider_type, model, base_url, api_key, reasoning_effort, is_active
)
async def get_provider_profiles(self) -> List[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
ORDER BY p.id ASC;
"""
)
return [AIProviderProfile(**dict(r)) for r in rows]
async def get_provider_profile_by_id(self, profile_id: int) -> Optional[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
WHERE p.id = $1;
""",
profile_id
)
return AIProviderProfile(**dict(row)) if row else None
async def get_active_provider_profile(self) -> Optional[AIProviderProfile]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
p.fallback_provider_id,
fb.name as fallback_provider_name,
to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
FROM ai_providers p
LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
WHERE p.is_active = TRUE
ORDER BY p.id ASC
LIMIT 1;
"""
)
return AIProviderProfile(**dict(row)) if row else None
async def set_active_provider_profile(self, profile_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE ai_providers SET is_active = FALSE;")
await conn.execute("UPDATE ai_providers SET is_active = TRUE WHERE id = $1;", profile_id)
async def update_provider_fallback(self, profile_id: int, fallback_provider_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE ai_providers SET fallback_provider_id = $1 WHERE id = $2;",
fallback_provider_id, profile_id
)
async def update_provider_profile(
self,
profile_id: int,
name: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
reasoning_effort: Optional[str] = None,
) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE ai_providers
SET name = COALESCE($2, name),
model = COALESCE($3, model),
base_url = COALESCE($4, base_url),
api_key = COALESCE($5, api_key),
reasoning_effort = COALESCE($6, reasoning_effort)
WHERE id = $1;
""",
profile_id, name, model, base_url, api_key, reasoning_effort
)
async def update_provider_reasoning_effort(self, profile_id: int, reasoning_effort: str) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE ai_providers SET reasoning_effort = $1 WHERE id = $2;",
reasoning_effort, profile_id
)
async def delete_provider_profile(self, profile_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM ai_providers WHERE id = $1;", profile_id)
# --- Channel Categories ---
async def create_category(self, name: str, cat_type: str = "both", description: str = "") -> int:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO channel_categories (name, type, description)
VALUES ($1, $2, $3)
RETURNING id;
""",
name, cat_type, description
)
return row["id"]
async def get_categories(self, cat_type: Optional[str] = None) -> List[ChannelCategory]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if cat_type:
rows = await conn.fetch(
"SELECT * FROM channel_categories WHERE type = $1 OR type = 'both' ORDER BY id ASC;",
cat_type
)
else:
rows = await conn.fetch("SELECT * FROM channel_categories ORDER BY id ASC;")
return [ChannelCategory(**dict(r)) for r in rows]
async def get_category_by_id(self, cat_id: int) -> Optional[ChannelCategory]:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM channel_categories WHERE id = $1;", cat_id)
return ChannelCategory(**dict(row)) if row else None
async def update_category(self, cat_id: int, name: Optional[str] = None, description: Optional[str] = None, cat_type: Optional[str] = None) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE channel_categories
SET name = COALESCE($2, name),
description = COALESCE($3, description),
type = COALESCE($4, type)
WHERE id = $1;
""",
cat_id, name, description, cat_type
)
async def delete_category(self, cat_id: int) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET category_id = NULL WHERE category_id = $1;", cat_id)
await conn.execute("UPDATE targets SET category_id = NULL WHERE category_id = $1;", cat_id)
await conn.execute("DELETE FROM channel_categories WHERE id = $1;", cat_id)
async def set_source_category(self, source_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE sources SET category_id = $1 WHERE id = $2;", category_id, source_id)
async def set_target_category(self, target_id: int, category_id: Optional[int]) -> None:
pool = await self._get_pool()
async with pool.acquire() as conn:
await conn.execute("UPDATE targets SET category_id = $1 WHERE id = $2;", category_id, target_id)
async def get_sources_by_category(self, category_id: Optional[int]) -> List[SourceChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM sources WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [SourceChannel(**dict(r)) for r in rows]
async def get_targets_by_category(self, category_id: Optional[int]) -> List[TargetChannel]:
pool = await self._get_pool()
async with pool.acquire() as conn:
if category_id is None:
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE AND category_id IS NULL ORDER BY id ASC;")
else:
rows = await conn.fetch("SELECT * FROM targets WHERE is_active = TRUE AND category_id = $1 ORDER BY id ASC;", category_id)
return [TargetChannel(**dict(r)) for r in rows]
async def get_category_channel_counts(self, category_id: int) -> Dict[str, int]:
pool = await self._get_pool()
async with pool.acquire() as conn:
src_count = await conn.fetchval("SELECT COUNT(*) FROM sources WHERE is_active = TRUE AND category_id = $1;", category_id) or 0
trg_count = await conn.fetchval("SELECT COUNT(*) FROM targets WHERE is_active = TRUE AND category_id = $1;", category_id) or 0
return {"sources": src_count, "targets": trg_count}
# --- System Global Operational State ---
async def is_system_paused(self) -> bool:
val = await self.get_setting("system_is_paused", "false")
return str(val).strip().lower() in ("true", "1", "yes")
async def set_system_paused(self, paused: bool) -> None:
await self.set_setting(
"system_is_paused",
"true" if paused else "false",
description="Global emergency operational pause"
)