419 lines
14 KiB
Python
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)
|