core/services/admin.py
2025-07-03 00:20:10 +03:00

580 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сервис админ-панели с бизнес-логикой для управления пользователями, публикациями и приглашениями.
"""
from math import ceil
from typing import Any
from sqlalchemy import String, cast, null, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import func, select
from auth.orm import Author
from orm.community import Community, CommunityAuthor
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.db import local_session
from services.env import EnvManager, EnvVariable
from utils.logger import root_logger as logger
class AdminService:
"""Сервис для админ-панели с бизнес-логикой"""
@staticmethod
def normalize_pagination(limit: int = 20, offset: int = 0) -> tuple[int, int]:
"""Нормализует параметры пагинации"""
return max(1, min(100, limit or 20)), max(0, offset or 0)
@staticmethod
def calculate_pagination_info(total_count: int, limit: int, offset: int) -> dict[str, int]:
"""Вычисляет информацию о пагинации"""
per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
return {
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
@staticmethod
def get_author_info(author_id: int, session) -> dict[str, Any]:
"""Получает информацию об авторе"""
if not author_id or author_id == 0:
return {
"id": 0,
"email": "system@discours.io",
"name": "System",
"slug": "system",
}
author = session.query(Author).filter(Author.id == author_id).first()
if author:
return {
"id": author.id,
"email": author.email or f"user{author.id}@discours.io",
"name": author.name or f"User {author.id}",
"slug": author.slug or f"user-{author.id}",
}
return {
"id": author_id,
"email": f"deleted{author_id}@discours.io",
"name": f"Deleted User {author_id}",
"slug": f"deleted-user-{author_id}",
}
@staticmethod
def get_user_roles(user: Author, community_id: int = 1) -> list[str]:
"""Получает роли пользователя в сообществе"""
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
admin_emails = ADMIN_EMAILS_LIST.split(",") if ADMIN_EMAILS_LIST else []
user_roles = []
with local_session() as session:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == user.id, CommunityAuthor.community_id == community_id)
.first()
)
if community_author:
user_roles = community_author.role_list
# Добавляем синтетическую роль для системных админов
if user.email and user.email.lower() in [email.lower() for email in admin_emails]:
if "Системный администратор" not in user_roles:
user_roles.insert(0, "Системный администратор")
return user_roles
# === ПОЛЬЗОВАТЕЛИ ===
def get_users(self, limit: int = 20, offset: int = 0, search: str = "") -> dict[str, Any]:
"""Получает список пользователей"""
limit, offset = self.normalize_pagination(limit, offset)
with local_session() as session:
query = session.query(Author)
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = query.filter(
or_(
Author.email.ilike(search_term),
Author.name.ilike(search_term),
cast(Author.id, String).ilike(search_term),
)
)
total_count = query.count()
authors = query.order_by(Author.id).offset(offset).limit(limit).all()
pagination_info = self.calculate_pagination_info(total_count, limit, offset)
return {
"authors": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"slug": user.slug,
"roles": self.get_user_roles(user, 1),
"created_at": user.created_at,
"last_seen": user.last_seen,
}
for user in authors
],
**pagination_info,
}
def update_user(self, user_data: dict[str, Any]) -> dict[str, Any]:
"""Обновляет данные пользователя"""
user_id = user_data.get("id")
if not user_id:
return {"success": False, "error": "ID пользователя не указан"}
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
return {"success": False, "error": "Некорректный ID пользователя"}
roles = user_data.get("roles", [])
email = user_data.get("email")
name = user_data.get("name")
slug = user_data.get("slug")
with local_session() as session:
author = session.query(Author).filter(Author.id == user_id).first()
if not author:
return {"success": False, "error": f"Пользователь с ID {user_id} не найден"}
# Обновляем основные поля
if email is not None and email != author.email:
existing = session.query(Author).filter(Author.email == email, Author.id != user_id).first()
if existing:
return {"success": False, "error": f"Email {email} уже используется"}
author.email = email
if name is not None and name != author.name:
author.name = name
if slug is not None and slug != author.slug:
existing = session.query(Author).filter(Author.slug == slug, Author.id != user_id).first()
if existing:
return {"success": False, "error": f"Slug {slug} уже используется"}
author.slug = slug
# Обновляем роли
if roles is not None:
community_author = (
session.query(CommunityAuthor)
.filter(CommunityAuthor.author_id == user_id_int, CommunityAuthor.community_id == 1)
.first()
)
if not community_author:
community_author = CommunityAuthor(author_id=user_id_int, community_id=1, roles="")
session.add(community_author)
# Валидация ролей
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
valid_roles = [role for role in roles if role in all_roles]
community_author.set_roles(valid_roles)
session.commit()
logger.info(f"Пользователь {author.email or author.id} обновлен")
return {"success": True}
# === ПУБЛИКАЦИИ ===
def get_shouts(
self,
limit: int = 20,
offset: int = 0,
search: str = "",
status: str = "all",
community: int = None,
) -> dict[str, Any]:
"""Получает список публикаций"""
limit = max(1, min(100, limit or 10))
offset = max(0, offset or 0)
with local_session() as session:
q = select(Shout).options(joinedload(Shout.authors), joinedload(Shout.topics))
# Фильтр статуса
if status == "published":
q = q.filter(Shout.published_at.isnot(None), Shout.deleted_at.is_(None))
elif status == "draft":
q = q.filter(Shout.published_at.is_(None), Shout.deleted_at.is_(None))
elif status == "deleted":
q = q.filter(Shout.deleted_at.isnot(None))
# Фильтр по сообществу
if community is not None:
q = q.filter(Shout.community == community)
# Поиск
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
q = q.filter(
or_(
Shout.title.ilike(search_term),
Shout.slug.ilike(search_term),
cast(Shout.id, String).ilike(search_term),
Shout.body.ilike(search_term),
)
)
total_count = session.execute(select(func.count()).select_from(q.subquery())).scalar()
q = q.order_by(Shout.created_at.desc()).limit(limit).offset(offset)
shouts_result = session.execute(q).unique().scalars().all()
shouts_data = []
for shout in shouts_result:
shout_dict = self._serialize_shout(shout, session)
if shout_dict is not None: # Фильтруем объекты с отсутствующими обязательными полями
shouts_data.append(shout_dict)
per_page = limit or 20
total_pages = ceil((total_count or 0) / per_page) if per_page > 0 else 1
current_page = (offset // per_page) + 1 if per_page > 0 else 1
return {
"shouts": shouts_data,
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
def _serialize_shout(self, shout, session) -> dict[str, Any] | None:
"""Сериализует публикацию в словарь"""
# Проверяем обязательные поля перед сериализацией
if not hasattr(shout, "id") or not shout.id:
logger.warning(f"Shout без ID найден, пропускаем: {shout}")
return None
# Обрабатываем media
media_data = []
if hasattr(shout, "media") and shout.media:
if isinstance(shout.media, str):
try:
import orjson
media_data = orjson.loads(shout.media)
except Exception:
media_data = []
elif isinstance(shout.media, list):
media_data = shout.media
# Получаем информацию о создателе (обязательное поле)
created_by_info = self.get_author_info(getattr(shout, "created_by", None) or 0, session)
# Получаем информацию о сообществе (обязательное поле)
community_info = self._get_community_info(getattr(shout, "community", None) or 0, session)
return {
"id": shout.id, # Обязательное поле
"title": getattr(shout, "title", "") or "", # Обязательное поле
"slug": getattr(shout, "slug", "") or f"shout-{shout.id}", # Обязательное поле
"body": getattr(shout, "body", "") or "", # Обязательное поле
"lead": getattr(shout, "lead", None),
"subtitle": getattr(shout, "subtitle", None),
"layout": getattr(shout, "layout", "article") or "article", # Обязательное поле
"lang": getattr(shout, "lang", "ru") or "ru", # Обязательное поле
"cover": getattr(shout, "cover", None),
"cover_caption": getattr(shout, "cover_caption", None),
"media": media_data,
"seo": getattr(shout, "seo", None),
"created_at": getattr(shout, "created_at", 0) or 0, # Обязательное поле
"updated_at": getattr(shout, "updated_at", None),
"published_at": getattr(shout, "published_at", None),
"featured_at": getattr(shout, "featured_at", None),
"deleted_at": getattr(shout, "deleted_at", None),
"created_by": created_by_info, # Обязательное поле
"updated_by": self.get_author_info(getattr(shout, "updated_by", None) or 0, session),
"deleted_by": self.get_author_info(getattr(shout, "deleted_by", None) or 0, session),
"community": community_info, # Обязательное поле
"authors": [
{
"id": getattr(author, "id", None),
"email": getattr(author, "email", None),
"name": getattr(author, "name", None),
"slug": getattr(author, "slug", None) or f"user-{getattr(author, 'id', 'unknown')}",
}
for author in getattr(shout, "authors", [])
],
"topics": [
{
"id": getattr(topic, "id", None),
"title": getattr(topic, "title", None),
"slug": getattr(topic, "slug", None),
}
for topic in getattr(shout, "topics", [])
],
"version_of": getattr(shout, "version_of", None),
"draft": getattr(shout, "draft", None),
"stat": None,
}
def _get_community_info(self, community_id: int, session) -> dict[str, Any]:
"""Получает информацию о сообществе"""
if not community_id or community_id == 0:
return {
"id": 1, # Default community ID
"name": "Дискурс",
"slug": "discours",
}
community = session.query(Community).filter(Community.id == community_id).first()
if community:
return {
"id": community.id,
"name": community.name or f"Community {community.id}",
"slug": community.slug or f"community-{community.id}",
}
return {
"id": community_id,
"name": f"Unknown Community {community_id}",
"slug": f"unknown-community-{community_id}",
}
def restore_shout(self, shout_id: int) -> dict[str, Any]:
"""Восстанавливает удаленную публикацию"""
with local_session() as session:
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"}
if not shout.deleted_at:
return {"success": False, "error": "Публикация не была удалена"}
shout.deleted_at = null()
shout.deleted_by = null()
session.commit()
logger.info(f"Публикация {shout.title or shout.id} восстановлена")
return {"success": True}
# === ПРИГЛАШЕНИЯ ===
def get_invites(self, limit: int = 20, offset: int = 0, search: str = "", status: str = "all") -> dict[str, Any]:
"""Получает список приглашений"""
limit, offset = self.normalize_pagination(limit, offset)
with local_session() as session:
query = session.query(Invite).options(
joinedload(Invite.inviter),
joinedload(Invite.author),
joinedload(Invite.shout),
)
# Фильтр по статусу
if status and status != "all":
status_enum = InviteStatus[status.upper()]
query = query.filter(Invite.status == status_enum.value)
# Поиск
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = query.filter(
or_(
Invite.inviter.has(Author.email.ilike(search_term)),
Invite.inviter.has(Author.name.ilike(search_term)),
Invite.author.has(Author.email.ilike(search_term)),
Invite.author.has(Author.name.ilike(search_term)),
Invite.shout.has(Shout.title.ilike(search_term)),
cast(Invite.inviter_id, String).ilike(search_term),
cast(Invite.author_id, String).ilike(search_term),
cast(Invite.shout_id, String).ilike(search_term),
)
)
total_count = query.count()
invites = (
query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all()
)
pagination_info = self.calculate_pagination_info(total_count, limit, offset)
result_invites = []
for invite in invites:
created_by_info = self.get_author_info(
(invite.shout.created_by if invite.shout else None) or 0, session
)
result_invites.append(
{
"inviter_id": invite.inviter_id,
"author_id": invite.author_id,
"shout_id": invite.shout_id,
"status": invite.status,
"inviter": {
"id": invite.inviter.id,
"name": invite.inviter.name or "Без имени",
"email": invite.inviter.email,
"slug": invite.inviter.slug or f"user-{invite.inviter.id}",
},
"author": {
"id": invite.author.id,
"name": invite.author.name or "Без имени",
"email": invite.author.email,
"slug": invite.author.slug or f"user-{invite.author.id}",
},
"shout": {
"id": invite.shout.id,
"title": invite.shout.title,
"slug": invite.shout.slug,
"created_by": created_by_info,
},
"created_at": None,
}
)
return {
"invites": result_invites,
**pagination_info,
}
def update_invite(self, invite_data: dict[str, Any]) -> dict[str, Any]:
"""Обновляет приглашение"""
inviter_id = invite_data["inviter_id"]
author_id = invite_data["author_id"]
shout_id = invite_data["shout_id"]
new_status = invite_data["status"]
with local_session() as session:
invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not invite:
return {"success": False, "error": "Приглашение не найдено"}
old_status = invite.status
invite.status = new_status
session.commit()
logger.info(f"Статус приглашения обновлен: {old_status}{new_status}")
return {"success": True, "error": None}
def delete_invite(self, inviter_id: int, author_id: int, shout_id: int) -> dict[str, Any]:
"""Удаляет приглашение"""
with local_session() as session:
invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not invite:
return {"success": False, "error": "Приглашение не найдено"}
session.delete(invite)
session.commit()
logger.info(f"Приглашение {inviter_id}-{author_id}-{shout_id} удалено")
return {"success": True, "error": None}
# === ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ ===
async def get_env_variables(self) -> list[dict[str, Any]]:
"""Получает переменные окружения"""
env_manager = EnvManager()
sections = await env_manager.get_all_variables()
return [
{
"name": section.name,
"description": section.description,
"variables": [
{
"key": var.key,
"value": var.value,
"description": var.description,
"type": var.type,
"isSecret": var.is_secret,
}
for var in section.variables
],
}
for section in sections
]
async def update_env_variable(self, key: str, value: str) -> dict[str, Any]:
"""Обновляет переменную окружения"""
try:
env_manager = EnvManager()
result = env_manager.update_variables([EnvVariable(key=key, value=value)])
if result:
logger.info(f"Переменная '{key}' обновлена")
return {"success": True, "error": None}
return {"success": False, "error": f"Не удалось обновить переменную '{key}'"}
except Exception as e:
logger.error(f"Ошибка обновления переменной: {e}")
return {"success": False, "error": str(e)}
async def update_env_variables(self, variables: list[dict[str, Any]]) -> dict[str, Any]:
"""Массовое обновление переменных окружения"""
try:
env_manager = EnvManager()
env_variables = [
EnvVariable(key=var.get("key", ""), value=var.get("value", ""), type=var.get("type", "string"))
for var in variables
]
result = env_manager.update_variables(env_variables)
if result:
logger.info(f"Обновлено {len(variables)} переменных")
return {"success": True, "error": None}
return {"success": False, "error": "Не удалось обновить переменные"}
except Exception as e:
logger.error(f"Ошибка массового обновления: {e}")
return {"success": False, "error": str(e)}
# === РОЛИ ===
def get_roles(self, community: int = None) -> list[dict[str, Any]]:
"""Получает список ролей"""
from orm.community import role_descriptions, role_names
all_roles = ["reader", "author", "artist", "expert", "editor", "admin"]
if community is not None:
with local_session() as session:
community_obj = session.query(Community).filter(Community.id == community).first()
available_roles = community_obj.get_available_roles() if community_obj else all_roles
else:
available_roles = all_roles
return [
{
"id": role_id,
"name": role_names.get(role_id, role_id.title()),
"description": role_descriptions.get(role_id, f"Роль {role_id}"),
}
for role_id in available_roles
]
# Синглтон сервиса
admin_service = AdminService()