2025-12-04 10:04:21 +08:00

419 lines
14 KiB
Python

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)