""" Сервис админ-панели с бизнес-логикой для управления пользователями, публикациями и приглашениями. """ from math import ceil from typing import Any import orjson 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, role_descriptions, role_names from orm.invite import Invite, InviteStatus from orm.shout import Shout from services.db import local_session from services.env import EnvVariable, env_manager from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST 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 total_pages = 1 if total_count is None or per_page in (None, 0) else 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).where(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]: """Получает роли пользователя в сообществе""" admin_emails = ADMIN_EMAILS_LIST.split(",") if ADMIN_EMAILS_LIST else [] user_roles = [] with local_session() as session: # Получаем все CommunityAuthor для пользователя all_community_authors = session.query(CommunityAuthor).where(CommunityAuthor.author_id == user.id).all() # Сначала ищем точное совпадение по community_id community_author = ( session.query(CommunityAuthor) .where(CommunityAuthor.author_id == user.id, CommunityAuthor.community_id == community_id) .first() ) # Если точного совпадения нет, используем первый найденный CommunityAuthor if not community_author and all_community_authors: community_author = all_community_authors[0] if ( community_author and community_author.roles is not None and community_author.roles.strip() and community_author.role_list ): user_roles = community_author.role_list # Добавляем синтетическую роль для системных админов if ( user.email and user.email.lower() in [email.lower() for email in admin_emails] and "Системный администратор" 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.where( 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).where(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).where(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).where(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) .where(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, "name": author.name, "email": author.email, "slug": author.slug, "roles": self.get_user_roles(author), } # === ПУБЛИКАЦИИ === async def get_shouts( self, page: int = 1, per_page: int = 20, search: str = "", status: str = "all", community: int | None = None, ) -> dict[str, Any]: """Получает список публикаций""" limit = max(1, min(100, per_page or 10)) offset = max(0, (page - 1) * limit) with local_session() as session: q = select(Shout).options(joinedload(Shout.authors), joinedload(Shout.topics)) # Фильтр статуса if status == "published": q = q.where(Shout.published_at.isnot(None), Shout.deleted_at.is_(None)) elif status == "draft": q = q.where(Shout.published_at.is_(None), Shout.deleted_at.is_(None)) elif status == "deleted": q = q.where(Shout.deleted_at.isnot(None)) # Фильтр по сообществу if community is not None: q = q.where(Shout.community == community) # Поиск if search and search.strip(): search_term = f"%{search.strip().lower()}%" q = q.where( 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: 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).where(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).where(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.where(Invite.status == status_enum.value) # Поиск if search and search.strip(): search_term = f"%{search.strip().lower()}%" query = query.where( 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) .where( 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) .where( 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]]: """Получает переменные окружения""" 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 if hasattr(var, "type") else None, "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: result = await env_manager.update_variables( [ EnvVariable( key=key, value=value, description=env_manager.get_variable_description(key), is_secret=key in env_manager.SECRET_VARIABLES, ) ] ) 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_variables = [ EnvVariable( key=var.get("key", ""), value=var.get("value", ""), description=env_manager.get_variable_description(var.get("key", "")), is_secret=var.get("key", "") in env_manager.SECRET_VARIABLES, ) for var in variables ] result = await 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 = None) -> list[dict[str, Any]]: """Получает список ролей""" all_roles = ["reader", "author", "artist", "expert", "editor", "admin"] if community is not None: with local_session() as session: community_obj = session.query(Community).where(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()