from typing import List, Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from app.api.dependencies.admin import get_admin_user from app.api.dependencies.database import get_repository from app.db.repositories.admin import AdminRepository from app.db.repositories.articles import ArticlesRepository from app.db.repositories.home_featured import HomeFeaturedRepository from app.db.repositories.menu_slots import DEFAULT_MENU_SLOTS, MenuSlotsRepository from app.db.repositories.roles import RolesRepository from app.db.repositories.users import UsersRepository from app.models.domain.users import User from app.models.schemas.admin import ( AdminHomeFeaturedUpdate, AdminMenuSlot, AdminMenuSlotListResponse, AdminMenuSlotResponse, AdminMenuSlotUpdate, AdminDashboardStats, AdminUserCreate, AdminUserListResponse, AdminUserResponse, AdminUserUpdate, ) from app.models.schemas.articles import ( ArticleForResponse, ArticleInResponse, ArticleInUpdate, ListOfArticlesInResponse, ) from app.models.schemas.roles import ( ListOfRolesInResponse, RoleInCreate, RoleInResponse, RoleInUpdate, ) from app.services.articles import get_slug_for_article router = APIRouter(prefix="/admin", tags=["admin"]) DEFAULT_MENU_SLOT_KEYS = {slot["slot_key"] for slot in DEFAULT_MENU_SLOTS} @router.get("/dashboard", response_model=AdminDashboardStats, name="admin:dashboard") async def get_dashboard_stats( _: User = Depends(get_admin_user), admin_repo: AdminRepository = Depends(get_repository(AdminRepository)), ) -> AdminDashboardStats: return await admin_repo.get_dashboard_stats() @router.get("/users", response_model=AdminUserListResponse, name="admin:list-users") async def list_admin_users( _: User = Depends(get_admin_user), search: Optional[str] = Query(default=None, description="Search by username/email"), role_id: Optional[int] = Query(default=None), limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), admin_repo: AdminRepository = Depends(get_repository(AdminRepository)), ) -> AdminUserListResponse: users, total = await admin_repo.list_users( search=search, role_id=role_id, limit=limit, offset=offset, ) return AdminUserListResponse(users=users, total=total) @router.post( "/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED, name="admin:create-user", ) async def create_admin_user( _: User = Depends(get_admin_user), payload: AdminUserCreate = Body(..., embed=True, alias="user"), users_repo: UsersRepository = Depends(get_repository(UsersRepository)), roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), admin_repo: AdminRepository = Depends(get_repository(AdminRepository)), ) -> AdminUserResponse: user = await users_repo.create_user( username=payload.username, email=payload.email, password=payload.password, ) # optional profile info if payload.bio or payload.image: user = await users_repo.update_user_by_id( user_id=user.id, bio=payload.bio, image=payload.image, ) if payload.role_ids: await roles_repo.set_roles_for_user( user_id=user.id, role_ids=payload.role_ids, ) summary = await admin_repo.get_user_summary(user.id) if not summary: raise HTTPException(status_code=500, detail="Failed to load created user") return AdminUserResponse(user=summary) @router.put( "/users/{user_id}", response_model=AdminUserResponse, name="admin:update-user", ) async def update_admin_user( _: User = Depends(get_admin_user), user_id: int = 0, payload: AdminUserUpdate = Body(..., embed=True, alias="user"), users_repo: UsersRepository = Depends(get_repository(UsersRepository)), roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), admin_repo: AdminRepository = Depends(get_repository(AdminRepository)), ) -> AdminUserResponse: await users_repo.update_user_by_id( user_id=user_id, username=payload.username, email=payload.email, password=payload.password, bio=payload.bio, image=payload.image, ) if payload.role_ids is not None: await roles_repo.set_roles_for_user( user_id=user_id, role_ids=payload.role_ids, ) summary = await admin_repo.get_user_summary(user_id) if not summary: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") return AdminUserResponse(user=summary) @router.delete( "/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, name="admin:delete-user", ) async def delete_admin_user( _: User = Depends(get_admin_user), user_id: int = 0, users_repo: UsersRepository = Depends(get_repository(UsersRepository)), ) -> None: await users_repo.delete_user_by_id(user_id=user_id) @router.get("/roles", response_model=ListOfRolesInResponse, name="admin:list-roles") async def list_roles( _: User = Depends(get_admin_user), roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), ) -> ListOfRolesInResponse: roles = await roles_repo.list_roles() return ListOfRolesInResponse(roles=roles) @router.post( "/roles", response_model=RoleInResponse, status_code=status.HTTP_201_CREATED, name="admin:create-role", ) async def create_role( _: User = Depends(get_admin_user), payload: RoleInCreate = Body(..., embed=True, alias="role"), roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), ) -> RoleInResponse: role = await roles_repo.create_role( name=payload.name, description=payload.description or "", permissions=payload.permissions, ) return RoleInResponse(role=role) @router.put( "/roles/{role_id}", response_model=RoleInResponse, name="admin:update-role", ) async def update_role( _: User = Depends(get_admin_user), role_id: int = 0, payload: RoleInUpdate = Body(..., embed=True, alias="role"), roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), ) -> RoleInResponse: role = await roles_repo.update_role( role_id=role_id, name=payload.name, description=payload.description, permissions=payload.permissions, ) return RoleInResponse(role=role) @router.delete( "/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT, name="admin:delete-role", ) async def delete_role( _: User = Depends(get_admin_user), role_id: int = 0, roles_repo: RolesRepository = Depends(get_repository(RolesRepository)), ) -> None: await roles_repo.delete_role(role_id=role_id) @router.get( "/menu-slots", response_model=AdminMenuSlotListResponse, name="admin:list-menu-slots", ) async def list_menu_slots( _: User = Depends(get_admin_user), menu_slots_repo: MenuSlotsRepository = Depends(get_repository(MenuSlotsRepository)), ) -> AdminMenuSlotListResponse: rows = await menu_slots_repo.list_slots() slots: List[AdminMenuSlot] = [] for row in rows: default_label = next( (slot["label"] for slot in DEFAULT_MENU_SLOTS if slot["slot_key"] == row["slot_key"]), row["slot_key"], ) slots.append( AdminMenuSlot( slot_key=row["slot_key"], label=row["label"] or default_label, tags=row["tags"] or [], created_at=row.get("created_at"), updated_at=row.get("updated_at"), ), ) return AdminMenuSlotListResponse(slots=slots) @router.put( "/menu-slots/{slot_key}", response_model=AdminMenuSlotResponse, name="admin:update-menu-slot", ) async def update_menu_slot( _: User = Depends(get_admin_user), slot_key: str = "", payload: AdminMenuSlotUpdate = Body(..., embed=True, alias="slot"), menu_slots_repo: MenuSlotsRepository = Depends(get_repository(MenuSlotsRepository)), ) -> AdminMenuSlotResponse: if slot_key not in DEFAULT_MENU_SLOT_KEYS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid menu slot", ) row = await menu_slots_repo.upsert_slot_tags( slot_key=slot_key, tags=payload.tags, label=payload.label, ) default_label = next( (slot["label"] for slot in DEFAULT_MENU_SLOTS if slot["slot_key"] == slot_key), slot_key, ) slot = AdminMenuSlot( slot_key=row["slot_key"], label=row["label"] or default_label, tags=row["tags"] or [], created_at=row.get("created_at"), updated_at=row.get("updated_at"), ) return AdminMenuSlotResponse(slot=slot) @router.get( "/home-featured-articles", response_model=ListOfArticlesInResponse, name="admin:list-home-featured-articles", ) async def list_home_featured_articles_admin( _: User = Depends(get_admin_user), home_repo: HomeFeaturedRepository = Depends(get_repository(HomeFeaturedRepository)), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ListOfArticlesInResponse: slugs = await home_repo.list_slugs() articles = await articles_repo.list_articles_by_slugs( slugs=slugs, requested_user=None, ) articles_for_response = [ ArticleForResponse.from_orm(article) for article in articles ] return ListOfArticlesInResponse( articles=articles_for_response, articles_count=len(articles_for_response), ) @router.put( "/home-featured-articles", response_model=ListOfArticlesInResponse, name="admin:save-home-featured-articles", ) async def save_home_featured_articles_admin( _: User = Depends(get_admin_user), payload: AdminHomeFeaturedUpdate = Body(...), home_repo: HomeFeaturedRepository = Depends(get_repository(HomeFeaturedRepository)), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ListOfArticlesInResponse: input_slugs = [item.slug.strip() for item in payload.articles if item.slug] if not input_slugs: await home_repo.save_slugs(slugs=[]) return ListOfArticlesInResponse(articles=[], articles_count=0) slugs: List[str] = [] for slug in input_slugs: if slug and slug not in slugs: slugs.append(slug) slugs = slugs[:10] articles = await articles_repo.list_articles_by_slugs( slugs=slugs, requested_user=None, ) found_slugs = {article.slug for article in articles} missing = [slug for slug in slugs if slug not in found_slugs] if missing: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Articles not found: {', '.join(missing)}", ) await home_repo.save_slugs(slugs=slugs) articles_for_response = [ ArticleForResponse.from_orm(article) for article in articles ] return ListOfArticlesInResponse( articles=articles_for_response, articles_count=len(articles_for_response), ) @router.get( "/articles", response_model=ListOfArticlesInResponse, name="admin:list-articles", ) async def list_articles_admin( _: User = Depends(get_admin_user), search: Optional[str] = Query(default=None), author: Optional[str] = Query(default=None), limit: int = Query(default=20, ge=1, le=200), offset: int = Query(default=0, ge=0), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ListOfArticlesInResponse: articles, total = await articles_repo.list_articles_for_admin( search=search, author=author, limit=limit, offset=offset, ) articles_for_response = [ ArticleForResponse.from_orm(article) for article in articles ] return ListOfArticlesInResponse(articles=articles_for_response, articles_count=total) @router.put( "/articles/{slug}", response_model=ArticleInResponse, name="admin:update-article", ) async def update_article_admin( _: User = Depends(get_admin_user), slug: str = "", article_update: ArticleInUpdate = Body(..., embed=True, alias="article"), articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> ArticleInResponse: current_article = await articles_repo.get_article_by_slug( slug=slug, requested_user=None, ) new_slug = get_slug_for_article(article_update.title) if article_update.title else None cover_provided = "cover" in article_update.__fields_set__ article = await articles_repo.admin_update_article( article=current_article, slug=new_slug, title=article_update.title, body=article_update.body, description=article_update.description, cover=article_update.cover, is_top=article_update.is_top, is_featured=article_update.is_featured, sort_weight=article_update.sort_weight, cover_provided=cover_provided, ) return ArticleInResponse(article=ArticleForResponse.from_orm(article)) @router.delete( "/articles/{slug}", status_code=status.HTTP_204_NO_CONTENT, name="admin:delete-article", ) async def delete_article_admin( _: User = Depends(get_admin_user), slug: str = "", articles_repo: ArticlesRepository = Depends(get_repository(ArticlesRepository)), ) -> None: article = await articles_repo.get_article_by_slug(slug=slug, requested_user=None) await articles_repo.admin_delete_article(article=article)