/
DaniilSk
/
DesignLab
Обзор
Документация
Войти
/
DaniilSk
/
DesignLab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
backend/app/api/v1/admin.py
339 строк
16 KB
Даниил
feat(admin-auth): add protected admin api
13 июл 2026, 23:41
13 июл 2026, 23:41
406f05e
Код
Авторство
О чём код?
import os import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from passlib.context import CryptContext from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.config import get_settings from app.core.security import create_admin_token, get_current_admin from app.db.session import get_session from app.models.article import Article, ArticleBlock from app.models.audit import AuditLog from app.models.event import Event, EventRegistration, EventSpeaker from app.models.media import MediaFile from app.models.progress import LoyaltyLevel, LoyaltyProgram, LoyaltyReward, UserLoyaltyProgress, UserRewardClaim from app.models.speaker import Speaker from app.models.user import User from app.repositories.events import EventRepository from app.schemas.admin import ( AdminArticleBlockWrite, AdminDashboard, AdminEventPatch, AdminEventWrite, AdminLoginRequest, AdminLoyaltyLevelWrite, AdminLoyaltyRewardWrite, AdminMe, AdminMediaRead, AdminSpeakerWrite, AdminTokenResponse, AdminUserUpdate, ) from app.schemas.common import Page from app.schemas.event import ArticleRead, EventRead, SpeakerRead from app.schemas.loyalty import LoyaltyRead from app.schemas.user import UserRead from app.services.events import event_to_read from app.services.loyalty import LoyaltyService router = APIRouter() pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") ALLOWED_MEDIA_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/svg+xml": ".svg"} MAX_MEDIA_SIZE = 8 * 1024 * 1024 def verify_admin_credentials(username: str, password: str) -> None: settings = get_settings() if not settings.admin_username or not settings.admin_password_hash: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Admin auth is not configured") if username != settings.admin_username or not pwd_context.verify(password, settings.admin_password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") async def audit(session: AsyncSession, actor: str, action: str, entity_type: str, entity_id: UUID | None, changes: dict) -> None: session.add(AuditLog(actor_id=actor, action=action, entity_type=entity_type, entity_id=entity_id, changes=changes)) @router.post("/auth/login", response_model=AdminTokenResponse) async def admin_login(payload: AdminLoginRequest): verify_admin_credentials(payload.username, payload.password) return AdminTokenResponse(access_token=create_admin_token(payload.username), username=payload.username) @router.post("/auth/logout") async def admin_logout(_: str = Depends(get_current_admin)): return {"status": "ok"} @router.get("/auth/me", response_model=AdminMe) async def admin_me(username: str = Depends(get_current_admin)): return AdminMe(username=username) @router.get("/dashboard", response_model=AdminDashboard) async def dashboard(_: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): since = datetime.now(timezone.utc) - timedelta(days=7) users_total = await session.scalar(select(func.count(User.id))) new_users = await session.scalar(select(func.count(User.id)).where(User.created_at >= since)) active_events = await session.scalar(select(func.count(Event.id)).where(Event.status.in_(["published", "registration_open"]))) completed_events = await session.scalar(select(func.count(Event.id)).where(Event.status == "completed")) upcoming = await session.scalar(select(Event.short_title).where(Event.status == "registration_open").order_by(Event.starts_at.asc().nullslast()).limit(1)) registered_total = await session.scalar(select(func.count(EventRegistration.id)).where(EventRegistration.status == "registered")) claimed_rewards_total = await session.scalar(select(func.count(UserRewardClaim.id))) return AdminDashboard( users_total=users_total or 0, new_users=new_users or 0, active_events=active_events or 0, completed_events=completed_events or 0, upcoming_event_title=upcoming, registered_total=registered_total or 0, claimed_rewards_total=claimed_rewards_total or 0, ) @router.get("/users", response_model=Page[UserRead]) async def list_users( _: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session), q: str | None = None, status_filter: str | None = Query(default=None, alias="status"), limit: int = Query(30, ge=1, le=100), offset: int = Query(0, ge=0), ): filters = [] if q: filters.append(User.name.ilike(f"%{q}%") | User.email.ilike(f"%{q}%")) if status_filter: filters.append(User.status == status_filter) stmt = select(User).order_by(User.created_at.desc()).limit(limit).offset(offset) count_stmt = select(func.count(User.id)) if filters: stmt = stmt.where(*filters) count_stmt = count_stmt.where(*filters) return Page(items=list((await session.execute(stmt)).scalars().all()), total=int((await session.scalar(count_stmt)) or 0), limit=limit, offset=offset) @router.get("/users/{user_id}", response_model=UserRead) async def get_user(user_id: UUID, _: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): user = await session.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user @router.patch("/users/{user_id}", response_model=UserRead) async def patch_user(user_id: UUID, payload: AdminUserUpdate, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): user = await session.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") changes = payload.model_dump(exclude_unset=True) if payload.status is not None: user.status = payload.status if payload.points_delta is not None: progress = await session.scalar(select(UserLoyaltyProgress).where(UserLoyaltyProgress.user_id == user.id)) if progress: progress.points = max(0, progress.points + payload.points_delta) await audit(session, actor, "update", "user", user.id, changes) await session.commit() await session.refresh(user) return user @router.get("/events", response_model=Page[EventRead]) async def admin_events(_: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session), limit: int = 50, offset: int = 0): repo = EventRepository(session) events = list((await session.execute(select(Event).options(selectinload(Event.speakers).selectinload(EventSpeaker.speaker)).order_by(Event.display_order.asc()).limit(limit).offset(offset))).scalars().unique().all()) total = int((await session.scalar(select(func.count(Event.id)))) or 0) return Page(items=[await event_to_read(event, repo=repo) for event in events], total=total, limit=limit, offset=offset) @router.post("/events", response_model=EventRead) async def create_event(payload: AdminEventWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): event = Event(**payload.model_dump()) session.add(event) await session.flush() await audit(session, actor, "create", "event", event.id, payload.model_dump()) await session.commit() repo = EventRepository(session) return await event_to_read(await repo.get_by_slug(event.slug), repo=repo) @router.get("/events/{event_id}", response_model=EventRead) async def admin_event(event_id: UUID, _: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): event = await session.get(Event, event_id) if not event: raise HTTPException(status_code=404, detail="Event not found") return await event_to_read(event, repo=EventRepository(session)) @router.patch("/events/{event_id}", response_model=EventRead) async def patch_event(event_id: UUID, payload: AdminEventPatch, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): event = await session.get(Event, event_id) if not event: raise HTTPException(status_code=404, detail="Event not found") changes = payload.model_dump(exclude_unset=True) for key, value in changes.items(): setattr(event, key, value) await audit(session, actor, "update", "event", event.id, changes) await session.commit() await session.refresh(event) return await event_to_read(event, repo=EventRepository(session)) @router.delete("/events/{event_id}") async def delete_event(event_id: UUID, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): event = await session.get(Event, event_id) if not event: raise HTTPException(status_code=404, detail="Event not found") event.is_visible = False event.status = "draft" await audit(session, actor, "archive", "event", event.id, {}) await session.commit() return {"status": "archived"} @router.get("/events/{event_id}/article", response_model=ArticleRead) async def admin_article(event_id: UUID, _: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): article = await session.scalar(select(Article).options(selectinload(Article.blocks)).where(Article.event_id == event_id)) if not article: article = Article(event_id=event_id, status="draft") session.add(article) await session.commit() await session.refresh(article) article.blocks.sort(key=lambda block: block.display_order) return article @router.put("/events/{event_id}/article/blocks", response_model=ArticleRead) async def save_article_blocks(event_id: UUID, blocks: list[AdminArticleBlockWrite], actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): article = await session.scalar(select(Article).options(selectinload(Article.blocks)).where(Article.event_id == event_id)) if not article: article = Article(event_id=event_id, status="draft") session.add(article) await session.flush() for block in list(article.blocks): await session.delete(block) await session.flush() for block in blocks: session.add(ArticleBlock(article_id=article.id, **block.model_dump())) await audit(session, actor, "replace_blocks", "article", article.id, {"blocks_count": len(blocks)}) await session.commit() article = await session.scalar(select(Article).options(selectinload(Article.blocks)).where(Article.id == article.id)) article.blocks.sort(key=lambda block: block.display_order) return article @router.get("/speakers", response_model=list[SpeakerRead]) async def list_speakers(_: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): return list((await session.execute(select(Speaker).order_by(Speaker.name.asc()))).scalars().all()) @router.post("/speakers", response_model=SpeakerRead) async def create_speaker(payload: AdminSpeakerWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): speaker = Speaker(**payload.model_dump()) session.add(speaker) await session.flush() await audit(session, actor, "create", "speaker", speaker.id, payload.model_dump()) await session.commit() return speaker @router.patch("/speakers/{speaker_id}", response_model=SpeakerRead) async def patch_speaker(speaker_id: UUID, payload: AdminSpeakerWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): speaker = await session.get(Speaker, speaker_id) if not speaker: raise HTTPException(status_code=404, detail="Speaker not found") for key, value in payload.model_dump(exclude_unset=True).items(): setattr(speaker, key, value) await audit(session, actor, "update", "speaker", speaker.id, payload.model_dump(exclude_unset=True)) await session.commit() return speaker @router.get("/loyalty", response_model=LoyaltyRead) async def admin_loyalty(_: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): admin = await session.scalar(select(User).order_by(User.created_at.asc()).limit(1)) if not admin: raise HTTPException(status_code=404, detail="No users found") return await LoyaltyService(session).get_loyalty(admin.id) @router.post("/loyalty/levels") async def create_loyalty_level(payload: AdminLoyaltyLevelWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): level = LoyaltyLevel(**payload.model_dump()) session.add(level) await session.flush() await audit(session, actor, "create", "loyalty_level", level.id, payload.model_dump(mode="json")) await session.commit() return {"id": level.id} @router.patch("/loyalty/levels/{level_id}") async def patch_loyalty_level(level_id: UUID, payload: AdminLoyaltyLevelWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): level = await session.get(LoyaltyLevel, level_id) if not level: raise HTTPException(status_code=404, detail="Level not found") for key, value in payload.model_dump(exclude_unset=True).items(): setattr(level, key, value) await audit(session, actor, "update", "loyalty_level", level.id, payload.model_dump(exclude_unset=True, mode="json")) await session.commit() return {"id": level.id} @router.post("/loyalty/rewards") async def create_loyalty_reward(payload: AdminLoyaltyRewardWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): reward = LoyaltyReward(**payload.model_dump()) session.add(reward) await session.flush() await audit(session, actor, "create", "loyalty_reward", reward.id, payload.model_dump(mode="json")) await session.commit() return {"id": reward.id} @router.patch("/loyalty/rewards/{reward_id}") async def patch_loyalty_reward(reward_id: UUID, payload: AdminLoyaltyRewardWrite, actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): reward = await session.get(LoyaltyReward, reward_id) if not reward: raise HTTPException(status_code=404, detail="Reward not found") for key, value in payload.model_dump(exclude_unset=True).items(): setattr(reward, key, value) await audit(session, actor, "update", "loyalty_reward", reward.id, payload.model_dump(exclude_unset=True, mode="json")) await session.commit() return {"id": reward.id} @router.post("/media", response_model=AdminMediaRead) async def upload_media(file: UploadFile = File(...), actor: str = Depends(get_current_admin), session: AsyncSession = Depends(get_session)): if file.content_type not in ALLOWED_MEDIA_TYPES: raise HTTPException(status_code=422, detail="Unsupported media type") data = await file.read() if len(data) > MAX_MEDIA_SIZE: raise HTTPException(status_code=413, detail="File is too large") settings = get_settings() extension = ALLOWED_MEDIA_TYPES[file.content_type] name = f"{uuid.uuid4()}{extension}" media_dir = Path(settings.media_local_dir) media_dir.mkdir(parents=True, exist_ok=True) path = media_dir / name path.write_bytes(data) public_url = f"{settings.media_public_base_url.rstrip('/')}/{name}" media = MediaFile( storage_key=f"local/{name}", public_url=public_url, mime_type=file.content_type, size_bytes=len(data), alt_text=os.path.splitext(file.filename or name)[0], metadata_json={"uploaded_by": actor}, ) session.add(media) await session.flush() await audit(session, actor, "upload", "media", media.id, {"filename": file.filename, "size": len(data), "mime": file.content_type}) await session.commit() await session.refresh(media) return media