2695 lines
100 KiB
Python
2695 lines
100 KiB
Python
import csv
|
||
from datetime import datetime, timezone
|
||
from io import StringIO
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from geoalchemy2.functions import ST_X, ST_Y
|
||
from sqlalchemy import func, or_, select
|
||
from sqlalchemy import delete as sa_delete
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.deps import get_current_active_user, get_db
|
||
from app.core.security import create_access_token, create_refresh_token, get_password_hash, verify_password
|
||
from app.models.spot import Spot, SpotImage
|
||
from app.models.event import Event, EventPhoto, EventRegistration
|
||
from app.models.app_nav_config import AppNavConfig
|
||
from app.models.membership import MembershipPlan, UserMembership
|
||
from app.models.notification import Notification
|
||
from app.models.point_ledger import PointLedger
|
||
from app.models.promotion import Promotion
|
||
from app.models.report import Report
|
||
from app.models.shooting import ShootingApplication, ShootingRequest
|
||
from app.models.system_config import SystemConfig
|
||
from app.models.tag import SpotTag, Tag
|
||
from app.models.user import User
|
||
from app.models.audit_log import AuditLog
|
||
from app.schemas.admin import (
|
||
AdminAuditLogCreateRequest,
|
||
AdminAuditLogItem,
|
||
AdminAuditRequest,
|
||
AdminBatchAuditRequest,
|
||
AdminDashboardStats,
|
||
AdminEventCreateRequest,
|
||
AdminEventDetailItem,
|
||
AdminEventListItem,
|
||
AdminEventPhotoCreateRequest,
|
||
AdminEventPhotoItem,
|
||
AdminEventPhotoUpdateRequest,
|
||
AdminEventRegistrationCreateRequest,
|
||
AdminEventRegistrationItem,
|
||
AdminEventRegistrationUpdateRequest,
|
||
AdminEventUpdateRequest,
|
||
AdminLoginRequest,
|
||
AdminLoginResponse,
|
||
AdminMembershipPlanCreateRequest,
|
||
AdminMembershipPlanItem,
|
||
AdminMembershipPlanUpdateRequest,
|
||
AdminModuleCrudCoverage,
|
||
AdminModuleDesignItem,
|
||
AdminModuleDesignResponse,
|
||
AdminPointAdjustRequest,
|
||
AdminPointLedgerItem,
|
||
AdminNotificationCreateRequest,
|
||
AdminNotificationItem,
|
||
AdminNotificationUpdateRequest,
|
||
AdminReportItem,
|
||
AdminReportUpdateRequest,
|
||
AdminShootingListItem,
|
||
AdminShootingApplicationCreateRequest,
|
||
AdminShootingApplicationItem,
|
||
AdminShootingApplicationUpdateRequest,
|
||
AdminShootingCreateRequest,
|
||
AdminShootingDetailItem,
|
||
AdminShootingUpdateRequest,
|
||
AdminSpotCreateRequest,
|
||
AdminSpotDetailItem,
|
||
AdminSpotImageItem,
|
||
AdminSpotAuditRequest,
|
||
AdminSpotListItem,
|
||
AdminSpotUpdateRequest,
|
||
AdminSystemConfigCreateRequest,
|
||
AdminSystemConfigItem,
|
||
AdminSystemConfigUpdateRequest,
|
||
AdminUserOut,
|
||
AdminUserCreateRequest,
|
||
AdminUserListItem,
|
||
AdminUserMembershipCreateRequest,
|
||
AdminUserMembershipItem,
|
||
AdminUserMembershipUpdateRequest,
|
||
AdminUserUpdateRequest,
|
||
)
|
||
from app.schemas.app_nav_config import AppNavConfigCreate, AppNavConfigOut, AppNavConfigUpdate
|
||
from app.schemas.common import PageResponse
|
||
from app.schemas.promotion import PromotionCreate, PromotionOut, PromotionUpdate
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _assert_admin_role(user: User) -> None:
|
||
if user.role not in ("admin", "moderator"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Admin permission required",
|
||
)
|
||
|
||
|
||
def _build_csv_content(headers: list[str], rows: list[list[object]]) -> str:
|
||
buffer = StringIO()
|
||
writer = csv.writer(buffer)
|
||
writer.writerow(headers)
|
||
for row in rows:
|
||
writer.writerow(row)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def _normalize_image_urls(image_urls: list[str] | None) -> list[str]:
|
||
if not image_urls:
|
||
return []
|
||
normalized = []
|
||
for item in image_urls:
|
||
value = (item or "").strip()
|
||
if value:
|
||
normalized.append(value)
|
||
return normalized
|
||
|
||
|
||
async def _recount_tag_usage(db: AsyncSession, tag_ids: set[int]) -> None:
|
||
if not tag_ids:
|
||
return
|
||
for tag_id in tag_ids:
|
||
count_stmt = select(func.count(SpotTag.id)).where(SpotTag.tag_id == tag_id)
|
||
usage_count = (await db.execute(count_stmt)).scalar() or 0
|
||
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id))
|
||
tag = tag_result.scalar_one_or_none()
|
||
if tag:
|
||
tag.usage_count = int(usage_count)
|
||
|
||
|
||
def _spot_to_admin_detail(spot: Spot, lng: float | None = None, lat: float | None = None) -> AdminSpotDetailItem:
|
||
image_urls = [img.image_url for img in sorted(spot.images, key=lambda x: x.sort_order)]
|
||
tag_ids = [tag.id for tag in spot.tags]
|
||
return AdminSpotDetailItem(
|
||
id=spot.id,
|
||
title=spot.title,
|
||
city=spot.city,
|
||
longitude=lng if lng is not None else spot.longitude,
|
||
latitude=lat if lat is not None else spot.latitude,
|
||
description=spot.description,
|
||
transport=spot.transport,
|
||
best_time=spot.best_time,
|
||
difficulty=spot.difficulty,
|
||
is_free=spot.is_free,
|
||
price_min=float(spot.price_min) if spot.price_min is not None else None,
|
||
price_max=float(spot.price_max) if spot.price_max is not None else None,
|
||
audit_status=spot.audit_status,
|
||
reject_reason=spot.reject_reason,
|
||
creator_id=spot.creator_id,
|
||
tag_ids=tag_ids,
|
||
image_urls=image_urls,
|
||
images=[AdminSpotImageItem.model_validate(i) for i in sorted(spot.images, key=lambda x: x.sort_order)],
|
||
)
|
||
|
||
|
||
def _event_registration_item(registration: EventRegistration) -> AdminEventRegistrationItem:
|
||
return AdminEventRegistrationItem(
|
||
id=registration.id,
|
||
event_id=registration.event_id,
|
||
user_id=registration.user_id,
|
||
user_nickname=registration.user.nickname if registration.user else "",
|
||
status=registration.status,
|
||
created_at=registration.created_at,
|
||
)
|
||
|
||
|
||
def _event_photo_item(photo: EventPhoto) -> AdminEventPhotoItem:
|
||
return AdminEventPhotoItem(
|
||
id=photo.id,
|
||
event_id=photo.event_id,
|
||
uploader_id=photo.uploader_id,
|
||
uploader_nickname=photo.uploader.nickname if photo.uploader else "",
|
||
image_url=photo.image_url,
|
||
caption=photo.caption,
|
||
spot_id=photo.spot_id,
|
||
created_at=photo.created_at,
|
||
)
|
||
|
||
|
||
def _event_to_admin_detail(event: Event) -> AdminEventDetailItem:
|
||
registrations = sorted(event.registrations, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||
photos = sorted(event.photos, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||
return AdminEventDetailItem(
|
||
id=event.id,
|
||
title=event.title,
|
||
city=event.city,
|
||
description=event.description,
|
||
cover_url=event.cover_url,
|
||
location_name=event.location_name,
|
||
start_time=event.start_time,
|
||
end_time=event.end_time,
|
||
max_participants=event.max_participants,
|
||
spot_id=event.spot_id,
|
||
status=event.status,
|
||
audit_status=event.audit_status,
|
||
reject_reason=event.reject_reason,
|
||
registration_count=event.registration_count,
|
||
creator_id=event.creator_id,
|
||
registration_user_ids=[reg.user_id for reg in registrations if reg.status == "registered"],
|
||
photos=[_event_photo_item(photo) for photo in photos],
|
||
registrations=[_event_registration_item(reg) for reg in registrations],
|
||
)
|
||
|
||
|
||
def _shooting_application_item(application: ShootingApplication) -> AdminShootingApplicationItem:
|
||
return AdminShootingApplicationItem(
|
||
id=application.id,
|
||
request_id=application.request_id,
|
||
applicant_id=application.applicant_id,
|
||
applicant_nickname=application.applicant.nickname if application.applicant else "",
|
||
message=application.message,
|
||
status=application.status,
|
||
created_at=application.created_at,
|
||
)
|
||
|
||
|
||
def _shooting_to_admin_detail(request: ShootingRequest) -> AdminShootingDetailItem:
|
||
apps = sorted(request.applications, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||
return AdminShootingDetailItem(
|
||
id=request.id,
|
||
title=request.title,
|
||
city=request.city,
|
||
description=request.description,
|
||
style=request.style,
|
||
shoot_date=request.shoot_date,
|
||
is_free=request.is_free,
|
||
budget_min=float(request.budget_min) if request.budget_min is not None else None,
|
||
budget_max=float(request.budget_max) if request.budget_max is not None else None,
|
||
role_needed=request.role_needed,
|
||
max_applicants=request.max_applicants,
|
||
contact_info=request.contact_info,
|
||
spot_id=request.spot_id,
|
||
status=request.status,
|
||
audit_status=request.audit_status,
|
||
reject_reason=request.reject_reason,
|
||
creator_id=request.creator_id,
|
||
applications=[_shooting_application_item(item) for item in apps],
|
||
application_user_ids=[item.applicant_id for item in apps],
|
||
)
|
||
|
||
|
||
async def _sync_shooting_applications(
|
||
db: AsyncSession,
|
||
request: ShootingRequest,
|
||
applications_payload: list[dict],
|
||
) -> None:
|
||
await db.execute(sa_delete(ShootingApplication).where(ShootingApplication.request_id == request.id))
|
||
if not applications_payload:
|
||
return
|
||
|
||
applicant_ids = {int(item["applicant_id"]) for item in applications_payload}
|
||
user_rows = (await db.execute(select(User.id).where(User.id.in_(applicant_ids)))).scalars().all()
|
||
valid_user_ids = {int(user_id) for user_id in user_rows}
|
||
|
||
for item in applications_payload:
|
||
applicant_id = int(item["applicant_id"])
|
||
if applicant_id not in valid_user_ids:
|
||
continue
|
||
db.add(
|
||
ShootingApplication(
|
||
request_id=request.id,
|
||
applicant_id=applicant_id,
|
||
message=item.get("message"),
|
||
status=item.get("status") or "pending",
|
||
)
|
||
)
|
||
|
||
|
||
async def _sync_event_registrations(
|
||
db: AsyncSession,
|
||
event: Event,
|
||
registration_user_ids: list[int],
|
||
) -> None:
|
||
requested_user_ids = {int(user_id) for user_id in registration_user_ids}
|
||
if not requested_user_ids:
|
||
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event.id))
|
||
event.registration_count = 0
|
||
return
|
||
|
||
user_rows = (await db.execute(select(User.id).where(User.id.in_(requested_user_ids)))).scalars().all()
|
||
valid_user_ids = {int(user_id) for user_id in user_rows}
|
||
|
||
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event.id))
|
||
for user_id in valid_user_ids:
|
||
db.add(EventRegistration(event_id=event.id, user_id=user_id, status="registered"))
|
||
event.registration_count = len(valid_user_ids)
|
||
|
||
|
||
async def _sync_event_photos(
|
||
db: AsyncSession,
|
||
event: Event,
|
||
photos_payload: list[dict],
|
||
) -> None:
|
||
await db.execute(sa_delete(EventPhoto).where(EventPhoto.event_id == event.id))
|
||
if not photos_payload:
|
||
return
|
||
|
||
uploader_ids = {int(item["uploader_id"]) for item in photos_payload}
|
||
user_rows = (await db.execute(select(User.id).where(User.id.in_(uploader_ids)))).scalars().all()
|
||
valid_uploader_ids = {int(user_id) for user_id in user_rows}
|
||
|
||
for item in photos_payload:
|
||
uploader_id = int(item["uploader_id"])
|
||
if uploader_id not in valid_uploader_ids:
|
||
continue
|
||
image_url = item.get("image_url", "").strip()
|
||
if not image_url:
|
||
continue
|
||
db.add(
|
||
EventPhoto(
|
||
event_id=event.id,
|
||
uploader_id=uploader_id,
|
||
image_url=image_url,
|
||
caption=(item.get("caption") or None),
|
||
spot_id=item.get("spot_id"),
|
||
)
|
||
)
|
||
|
||
|
||
def _module_design_items() -> list[AdminModuleDesignItem]:
|
||
return [
|
||
AdminModuleDesignItem(
|
||
module_key="users",
|
||
module_name="用户与权限",
|
||
models=["User"],
|
||
api_endpoint_prefixes=["/users", "/auth", "/admin/auth", "/admin/users"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端用户 CRUD 与角色/状态管理已覆盖(删除为停用)",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="spots",
|
||
module_name="取景地",
|
||
models=["Spot", "SpotImage", "Tag", "SpotTag", "Rating", "Favorite", "Correction", "Comment"],
|
||
api_endpoint_prefixes=["/spots", "/tags", "/comments", "/ratings", "/favorites", "/corrections", "/admin/spots"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端已覆盖取景地完整 CRUD,支持图片与标签关系集中管理",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="events",
|
||
module_name="活动",
|
||
models=["Event", "EventRegistration", "EventPhoto"],
|
||
api_endpoint_prefixes=["/events", "/admin/events"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端已覆盖活动完整 CRUD,并支持报名记录与活动相册管理",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="shooting",
|
||
module_name="约拍",
|
||
models=["ShootingRequest", "ShootingApplication"],
|
||
api_endpoint_prefixes=["/shooting", "/admin/shooting"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端已覆盖约拍完整 CRUD,并支持申请记录管理",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="promotions",
|
||
module_name="推广位",
|
||
models=["Promotion"],
|
||
api_endpoint_prefixes=["/promotions", "/admin/promotions", "/admin/promotion-link-options"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端 CRUD 已覆盖,且关联对象通过筛选器选择(不直接输入 ID)",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="membership",
|
||
module_name="会员体系",
|
||
models=["MembershipPlan", "UserMembership", "PointLedger"],
|
||
api_endpoint_prefixes=["/membership", "/points", "/admin/membership/plans", "/admin/membership/user-memberships", "/admin/points/ledger", "/admin/points/adjust"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="partial",
|
||
notes="套餐与用户会员 CRUD 已覆盖,积分流水已支持查询与调账",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="notifications",
|
||
module_name="通知消息",
|
||
models=["Notification"],
|
||
api_endpoint_prefixes=["/notifications", "/admin/notifications"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端通知 CRUD 已覆盖",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="app_nav_config",
|
||
module_name="前端导航配置",
|
||
models=["AppNavConfig"],
|
||
api_endpoint_prefixes=["/ui-config/nav", "/admin/app-nav-configs"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="管理端 CRUD 已覆盖,支持 uni-icons 图标与颜色配置维护",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="audit_support",
|
||
module_name="审核与风控辅助",
|
||
models=["AuditLog", "Report"],
|
||
api_endpoint_prefixes=["/stats", "/admin/audit-logs", "/admin/reports"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="举报与审计管理 CRUD 已覆盖",
|
||
),
|
||
AdminModuleDesignItem(
|
||
module_key="system_rules",
|
||
module_name="模板与规则中心",
|
||
models=["SystemConfig"],
|
||
api_endpoint_prefixes=["/admin/system-configs"],
|
||
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
|
||
status="full",
|
||
notes="消息模板、规则引擎、举报SOP配置已覆盖",
|
||
),
|
||
]
|
||
|
||
|
||
@router.post("/auth/login", response_model=AdminLoginResponse)
|
||
async def admin_login(
|
||
payload: AdminLoginRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result = await db.execute(
|
||
select(User).where(
|
||
or_(User.phone == payload.account, User.email == payload.account)
|
||
)
|
||
)
|
||
user = result.scalar_one_or_none()
|
||
if not user or not verify_password(payload.password, user.password_hash):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Incorrect account or password",
|
||
)
|
||
_assert_admin_role(user)
|
||
if not user.is_active:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="User account is disabled",
|
||
)
|
||
|
||
return AdminLoginResponse(
|
||
access_token=create_access_token(user.id),
|
||
refresh_token=create_refresh_token(user.id),
|
||
user=AdminUserOut.model_validate(user),
|
||
)
|
||
|
||
|
||
@router.get("/users", response_model=PageResponse[AdminUserListItem])
|
||
async def admin_list_users(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
role: str | None = Query(default=None),
|
||
is_active: bool | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(
|
||
or_(User.nickname.ilike(like), User.phone.ilike(like), User.email.ilike(like))
|
||
)
|
||
if role:
|
||
conditions.append(User.role == role.strip())
|
||
if is_active is not None:
|
||
conditions.append(User.is_active == is_active)
|
||
|
||
total_stmt = select(func.count(User.id))
|
||
list_stmt = select(User)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(User.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[AdminUserListItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.get("/users/{user_id}", response_model=AdminUserListItem)
|
||
async def admin_get_user(
|
||
user_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
return AdminUserListItem.model_validate(user)
|
||
|
||
|
||
@router.post("/users", response_model=AdminUserListItem, status_code=201)
|
||
async def admin_create_user(
|
||
payload: AdminUserCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
if payload.phone:
|
||
exists_phone = await db.execute(select(User.id).where(User.phone == payload.phone))
|
||
if exists_phone.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="Phone already exists")
|
||
if payload.email:
|
||
exists_email = await db.execute(select(User.id).where(User.email == payload.email))
|
||
if exists_email.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="Email already exists")
|
||
|
||
user = User(
|
||
nickname=payload.nickname,
|
||
phone=payload.phone,
|
||
email=payload.email,
|
||
city=payload.city,
|
||
identity=payload.identity,
|
||
role=payload.role,
|
||
is_active=payload.is_active,
|
||
password_hash=get_password_hash(payload.password),
|
||
)
|
||
db.add(user)
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return AdminUserListItem.model_validate(user)
|
||
|
||
|
||
@router.put("/users/{user_id}", response_model=AdminUserListItem)
|
||
async def admin_update_user(
|
||
user_id: int,
|
||
payload: AdminUserUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "phone" in data and data["phone"] and data["phone"] != user.phone:
|
||
exists_phone = await db.execute(select(User.id).where(User.phone == data["phone"]))
|
||
if exists_phone.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="Phone already exists")
|
||
if "email" in data and data["email"] and data["email"] != user.email:
|
||
exists_email = await db.execute(select(User.id).where(User.email == data["email"]))
|
||
if exists_email.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="Email already exists")
|
||
|
||
password = data.pop("password", None)
|
||
for field, value in data.items():
|
||
setattr(user, field, value)
|
||
if password:
|
||
user.password_hash = get_password_hash(password)
|
||
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
return AdminUserListItem.model_validate(user)
|
||
|
||
|
||
@router.delete("/users/{user_id}")
|
||
async def admin_delete_user(
|
||
user_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
if current_user.id == user_id:
|
||
raise HTTPException(status_code=400, detail="Cannot delete current login user")
|
||
result = await db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
user.is_active = False
|
||
user.role = "user"
|
||
await db.commit()
|
||
return {"code": 0, "message": "disabled"}
|
||
|
||
|
||
@router.get("/auth/me", response_model=AdminUserOut)
|
||
async def admin_me(
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
return AdminUserOut.model_validate(current_user)
|
||
|
||
|
||
@router.get("/dashboard", response_model=AdminDashboardStats)
|
||
async def admin_dashboard(
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
spots_total = (await db.execute(select(func.count(Spot.id)))).scalar() or 0
|
||
spots_pending = (
|
||
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "pending"))
|
||
).scalar() or 0
|
||
spots_approved = (
|
||
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "approved"))
|
||
).scalar() or 0
|
||
spots_rejected = (
|
||
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "rejected"))
|
||
).scalar() or 0
|
||
users_total = (await db.execute(select(func.count(User.id)))).scalar() or 0
|
||
events_total = (await db.execute(select(func.count(Event.id)))).scalar() or 0
|
||
shooting_total = (await db.execute(select(func.count(ShootingRequest.id)))).scalar() or 0
|
||
|
||
return AdminDashboardStats(
|
||
spots_total=spots_total,
|
||
spots_pending=spots_pending,
|
||
spots_approved=spots_approved,
|
||
spots_rejected=spots_rejected,
|
||
users_total=users_total,
|
||
events_total=events_total,
|
||
shooting_total=shooting_total,
|
||
)
|
||
|
||
|
||
@router.get("/module-design", response_model=AdminModuleDesignResponse)
|
||
async def admin_module_design(
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
items = _module_design_items()
|
||
full_covered = sum(1 for i in items if i.status == "full")
|
||
partial_covered = sum(1 for i in items if i.status == "partial")
|
||
missing_covered = sum(1 for i in items if i.status == "missing")
|
||
return AdminModuleDesignResponse(
|
||
total_modules=len(items),
|
||
full_covered=full_covered,
|
||
partial_covered=partial_covered,
|
||
missing_covered=missing_covered,
|
||
items=items,
|
||
)
|
||
|
||
|
||
@router.get("/spots", response_model=PageResponse[AdminSpotListItem])
|
||
async def admin_list_spots(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
city: str | None = Query(default=None),
|
||
audit_status: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(Spot.title.ilike(like), Spot.description.ilike(like)))
|
||
if city:
|
||
conditions.append(Spot.city == city.strip())
|
||
if audit_status:
|
||
conditions.append(Spot.audit_status == audit_status.strip())
|
||
|
||
total_stmt = select(func.count(Spot.id))
|
||
list_stmt = select(Spot)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(Spot.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
|
||
return PageResponse(total=total, items=[AdminSpotListItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.get("/spots/export")
|
||
async def admin_export_spots(
|
||
keyword: str | None = Query(default=None),
|
||
city: str | None = Query(default=None),
|
||
audit_status: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(Spot.title.ilike(like), Spot.description.ilike(like)))
|
||
if city:
|
||
conditions.append(Spot.city == city.strip())
|
||
if audit_status:
|
||
conditions.append(Spot.audit_status == audit_status.strip())
|
||
|
||
stmt = select(Spot).order_by(Spot.created_at.desc()).limit(5000)
|
||
if conditions:
|
||
stmt = stmt.where(*conditions)
|
||
items = (await db.execute(stmt)).scalars().all()
|
||
rows = [
|
||
[
|
||
item.id,
|
||
item.title,
|
||
item.city,
|
||
item.audit_status,
|
||
item.creator_id,
|
||
item.is_free,
|
||
float(item.price_min) if item.price_min is not None else "",
|
||
float(item.price_max) if item.price_max is not None else "",
|
||
item.created_at.isoformat() if item.created_at else "",
|
||
]
|
||
for item in items
|
||
]
|
||
content = _build_csv_content(
|
||
["id", "title", "city", "audit_status", "creator_id", "is_free", "price_min", "price_max", "created_at"],
|
||
rows,
|
||
)
|
||
return {
|
||
"filename": f"spots_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||
"content": content,
|
||
"count": len(rows),
|
||
}
|
||
|
||
|
||
@router.get("/spot-tag-options")
|
||
async def admin_spot_tag_options(
|
||
keyword: str | None = Query(default=None),
|
||
limit: int = Query(default=50, ge=1, le=200),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
stmt = select(Tag.id, Tag.name).where(Tag.is_active.is_(True)).order_by(Tag.usage_count.desc(), Tag.id.desc()).limit(limit)
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
stmt = (
|
||
select(Tag.id, Tag.name)
|
||
.where(Tag.is_active.is_(True), Tag.name.ilike(like))
|
||
.order_by(Tag.usage_count.desc(), Tag.id.desc())
|
||
.limit(limit)
|
||
)
|
||
rows = (await db.execute(stmt)).all()
|
||
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
|
||
|
||
|
||
@router.get("/spots/{spot_id}", response_model=AdminSpotDetailItem)
|
||
async def admin_get_spot(
|
||
spot_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(
|
||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||
.where(Spot.id == spot_id)
|
||
)
|
||
row = result.one_or_none()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
return _spot_to_admin_detail(row[0], lng=row[1], lat=row[2])
|
||
|
||
|
||
@router.post("/spots", response_model=AdminSpotDetailItem, status_code=201)
|
||
async def admin_create_spot(
|
||
payload: AdminSpotCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
user_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
|
||
if user_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
|
||
spot = Spot(
|
||
title=payload.title,
|
||
city=payload.city,
|
||
location=func.ST_SetSRID(func.ST_MakePoint(payload.longitude, payload.latitude), 4326),
|
||
description=payload.description,
|
||
transport=payload.transport,
|
||
best_time=payload.best_time,
|
||
difficulty=payload.difficulty,
|
||
is_free=payload.is_free,
|
||
price_min=payload.price_min if not payload.is_free else None,
|
||
price_max=payload.price_max if not payload.is_free else None,
|
||
audit_status=payload.audit_status,
|
||
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
|
||
creator_id=payload.creator_id,
|
||
)
|
||
db.add(spot)
|
||
await db.flush()
|
||
|
||
image_urls = _normalize_image_urls(payload.image_urls)
|
||
for idx, image_url in enumerate(image_urls):
|
||
db.add(
|
||
SpotImage(
|
||
spot_id=spot.id,
|
||
image_url=image_url,
|
||
is_cover=(idx == 0),
|
||
sort_order=idx,
|
||
)
|
||
)
|
||
|
||
valid_tag_ids: set[int] = set()
|
||
if payload.tag_ids:
|
||
tag_rows = (
|
||
await db.execute(select(Tag.id).where(Tag.id.in_(payload.tag_ids), Tag.is_active.is_(True)))
|
||
).scalars().all()
|
||
valid_tag_ids = {int(tag_id) for tag_id in tag_rows}
|
||
for tag_id in valid_tag_ids:
|
||
db.add(SpotTag(spot_id=spot.id, tag_id=tag_id))
|
||
|
||
await _recount_tag_usage(db, valid_tag_ids)
|
||
await db.commit()
|
||
await db.refresh(spot)
|
||
return _spot_to_admin_detail(spot)
|
||
|
||
|
||
@router.put("/spots/{spot_id}", response_model=AdminSpotDetailItem)
|
||
async def admin_update_spot(
|
||
spot_id: int,
|
||
payload: AdminSpotUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||
spot = result.scalar_one_or_none()
|
||
if not spot:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
update_data = payload.model_dump(exclude_unset=True)
|
||
|
||
if "creator_id" in update_data and update_data["creator_id"] is not None:
|
||
user_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
|
||
if user_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
|
||
if "longitude" in update_data or "latitude" in update_data:
|
||
lng = update_data.pop("longitude", spot.longitude)
|
||
lat = update_data.pop("latitude", spot.latitude)
|
||
spot.location = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
|
||
|
||
if "is_free" in update_data and update_data["is_free"] is True:
|
||
update_data["price_min"] = None
|
||
update_data["price_max"] = None
|
||
|
||
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
|
||
update_data["reject_reason"] = None
|
||
|
||
image_urls = update_data.pop("image_urls", None)
|
||
tag_ids = update_data.pop("tag_ids", None)
|
||
|
||
for field, value in update_data.items():
|
||
setattr(spot, field, value)
|
||
|
||
affected_tag_ids: set[int] = set()
|
||
if tag_ids is not None:
|
||
old_tag_ids = {tag.id for tag in spot.tags}
|
||
affected_tag_ids.update(old_tag_ids)
|
||
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot.id))
|
||
valid_tag_ids: set[int] = set()
|
||
if tag_ids:
|
||
tag_rows = (
|
||
await db.execute(select(Tag.id).where(Tag.id.in_(tag_ids), Tag.is_active.is_(True)))
|
||
).scalars().all()
|
||
valid_tag_ids = {int(tag_id) for tag_id in tag_rows}
|
||
for tag_id in valid_tag_ids:
|
||
db.add(SpotTag(spot_id=spot.id, tag_id=tag_id))
|
||
affected_tag_ids.update(valid_tag_ids)
|
||
|
||
if image_urls is not None:
|
||
await db.execute(sa_delete(SpotImage).where(SpotImage.spot_id == spot.id))
|
||
normalized = _normalize_image_urls(image_urls)
|
||
for idx, image_url in enumerate(normalized):
|
||
db.add(
|
||
SpotImage(
|
||
spot_id=spot.id,
|
||
image_url=image_url,
|
||
is_cover=(idx == 0),
|
||
sort_order=idx,
|
||
)
|
||
)
|
||
|
||
await _recount_tag_usage(db, affected_tag_ids)
|
||
await db.commit()
|
||
await db.refresh(spot)
|
||
detail_result = await db.execute(
|
||
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
|
||
.where(Spot.id == spot.id)
|
||
)
|
||
row = detail_result.one()
|
||
return _spot_to_admin_detail(row[0], lng=row[1], lat=row[2])
|
||
|
||
|
||
@router.delete("/spots/{spot_id}")
|
||
async def admin_delete_spot(
|
||
spot_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||
spot = result.scalar_one_or_none()
|
||
if not spot:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
affected_tag_ids = {tag.id for tag in spot.tags}
|
||
await db.execute(sa_delete(SpotImage).where(SpotImage.spot_id == spot.id))
|
||
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot.id))
|
||
await db.delete(spot)
|
||
await _recount_tag_usage(db, affected_tag_ids)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.patch("/spots/{spot_id}/audit", response_model=AdminSpotListItem)
|
||
async def admin_audit_spot(
|
||
spot_id: int,
|
||
payload: AdminSpotAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
result = await db.execute(select(Spot).where(Spot.id == spot_id))
|
||
spot = result.scalar_one_or_none()
|
||
if not spot:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
spot.audit_status = payload.audit_status
|
||
spot.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
|
||
await db.commit()
|
||
await db.refresh(spot)
|
||
return AdminSpotListItem.model_validate(spot)
|
||
|
||
|
||
@router.post("/spots/batch-audit")
|
||
async def admin_batch_audit_spots(
|
||
payload: AdminBatchAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
ids = list({int(i) for i in payload.ids})
|
||
if not ids:
|
||
raise HTTPException(status_code=400, detail="ids is required")
|
||
rows = (await db.execute(select(Spot).where(Spot.id.in_(ids)))).scalars().all()
|
||
for item in rows:
|
||
item.audit_status = payload.audit_status
|
||
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
await db.commit()
|
||
return {"code": 0, "updated": len(rows)}
|
||
|
||
|
||
@router.get("/events", response_model=PageResponse[AdminEventListItem])
|
||
async def admin_list_events(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
city: str | None = Query(default=None),
|
||
status_filter: str | None = Query(default=None, alias="status"),
|
||
audit_status: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(Event.title.ilike(like), Event.description.ilike(like)))
|
||
if city:
|
||
conditions.append(Event.city == city.strip())
|
||
if status_filter:
|
||
conditions.append(Event.status == status_filter.strip())
|
||
if audit_status:
|
||
conditions.append(Event.audit_status == audit_status.strip())
|
||
|
||
total_stmt = select(func.count(Event.id))
|
||
list_stmt = select(Event)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(Event.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
|
||
return PageResponse(total=total, items=[AdminEventListItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.get("/events/{event_id}", response_model=AdminEventDetailItem)
|
||
async def admin_get_event(
|
||
event_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||
event = result.scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
return _event_to_admin_detail(event)
|
||
|
||
|
||
@router.post("/events", response_model=AdminEventDetailItem, status_code=201)
|
||
async def admin_create_event(
|
||
payload: AdminEventCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
creator_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
|
||
if creator_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
if payload.spot_id is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
event = Event(
|
||
creator_id=payload.creator_id,
|
||
title=payload.title,
|
||
city=payload.city,
|
||
description=payload.description,
|
||
cover_url=payload.cover_url,
|
||
location_name=payload.location_name,
|
||
start_time=payload.start_time,
|
||
end_time=payload.end_time,
|
||
max_participants=payload.max_participants,
|
||
spot_id=payload.spot_id,
|
||
status=payload.status,
|
||
audit_status=payload.audit_status,
|
||
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
|
||
)
|
||
db.add(event)
|
||
await db.flush()
|
||
|
||
await _sync_event_registrations(db, event, payload.registration_user_ids)
|
||
await _sync_event_photos(db, event, [item.model_dump() for item in payload.photos])
|
||
|
||
await db.commit()
|
||
result = await db.execute(select(Event).where(Event.id == event.id))
|
||
created = result.scalar_one()
|
||
return _event_to_admin_detail(created)
|
||
|
||
|
||
@router.put("/events/{event_id}", response_model=AdminEventDetailItem)
|
||
async def admin_update_event(
|
||
event_id: int,
|
||
payload: AdminEventUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||
event = result.scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
update_data = payload.model_dump(exclude_unset=True)
|
||
registration_user_ids = update_data.pop("registration_user_ids", None)
|
||
photos_payload = update_data.pop("photos", None)
|
||
|
||
if "creator_id" in update_data and update_data["creator_id"] is not None:
|
||
creator_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
|
||
if creator_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
if "spot_id" in update_data and update_data["spot_id"] is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
|
||
update_data["reject_reason"] = None
|
||
|
||
for field, value in update_data.items():
|
||
setattr(event, field, value)
|
||
|
||
if registration_user_ids is not None:
|
||
await _sync_event_registrations(db, event, registration_user_ids)
|
||
if photos_payload is not None:
|
||
await _sync_event_photos(db, event, photos_payload)
|
||
|
||
await db.commit()
|
||
refreshed = (await db.execute(select(Event).where(Event.id == event.id))).scalar_one()
|
||
return _event_to_admin_detail(refreshed)
|
||
|
||
|
||
@router.delete("/events/{event_id}")
|
||
async def admin_delete_event(
|
||
event_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||
event = result.scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event_id))
|
||
await db.execute(sa_delete(EventPhoto).where(EventPhoto.event_id == event_id))
|
||
await db.delete(event)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/events/{event_id}/registrations", response_model=list[AdminEventRegistrationItem])
|
||
async def admin_list_event_registrations(
|
||
event_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
rows = (
|
||
await db.execute(
|
||
select(EventRegistration)
|
||
.where(EventRegistration.event_id == event_id)
|
||
.order_by(EventRegistration.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
return [_event_registration_item(item) for item in rows]
|
||
|
||
|
||
@router.post("/events/{event_id}/registrations", response_model=AdminEventRegistrationItem, status_code=201)
|
||
async def admin_create_event_registration(
|
||
event_id: int,
|
||
payload: AdminEventRegistrationCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
user = (await db.execute(select(User).where(User.id == payload.user_id))).scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
exists = (
|
||
await db.execute(
|
||
select(EventRegistration).where(
|
||
EventRegistration.event_id == event_id,
|
||
EventRegistration.user_id == payload.user_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail="Registration already exists")
|
||
|
||
if payload.status == "registered" and event.max_participants > 0 and event.registration_count >= event.max_participants:
|
||
raise HTTPException(status_code=400, detail="Registration limit reached")
|
||
|
||
registration = EventRegistration(event_id=event_id, user_id=payload.user_id, status=payload.status)
|
||
db.add(registration)
|
||
|
||
count_stmt = select(func.count(EventRegistration.id)).where(
|
||
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
|
||
)
|
||
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0) + (1 if payload.status == "registered" else 0)
|
||
|
||
await db.commit()
|
||
await db.refresh(registration)
|
||
await db.refresh(event)
|
||
return _event_registration_item(registration)
|
||
|
||
|
||
@router.put("/events/{event_id}/registrations/{registration_id}", response_model=AdminEventRegistrationItem)
|
||
async def admin_update_event_registration(
|
||
event_id: int,
|
||
registration_id: int,
|
||
payload: AdminEventRegistrationUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
registration = (
|
||
await db.execute(
|
||
select(EventRegistration).where(
|
||
EventRegistration.id == registration_id,
|
||
EventRegistration.event_id == event_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not registration:
|
||
raise HTTPException(status_code=404, detail="Registration not found")
|
||
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
registration.status = payload.status
|
||
count_stmt = select(func.count(EventRegistration.id)).where(
|
||
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
|
||
)
|
||
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0)
|
||
|
||
await db.commit()
|
||
await db.refresh(registration)
|
||
return _event_registration_item(registration)
|
||
|
||
|
||
@router.delete("/events/{event_id}/registrations/{registration_id}")
|
||
async def admin_delete_event_registration(
|
||
event_id: int,
|
||
registration_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
registration = (
|
||
await db.execute(
|
||
select(EventRegistration).where(
|
||
EventRegistration.id == registration_id,
|
||
EventRegistration.event_id == event_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not registration:
|
||
raise HTTPException(status_code=404, detail="Registration not found")
|
||
|
||
await db.delete(registration)
|
||
count_stmt = select(func.count(EventRegistration.id)).where(
|
||
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
|
||
)
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if event:
|
||
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0)
|
||
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/events/{event_id}/photos", response_model=list[AdminEventPhotoItem])
|
||
async def admin_list_event_photos(
|
||
event_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
rows = (
|
||
await db.execute(
|
||
select(EventPhoto)
|
||
.where(EventPhoto.event_id == event_id)
|
||
.order_by(EventPhoto.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
return [_event_photo_item(item) for item in rows]
|
||
|
||
|
||
@router.post("/events/{event_id}/photos", response_model=AdminEventPhotoItem, status_code=201)
|
||
async def admin_create_event_photo(
|
||
event_id: int,
|
||
payload: AdminEventPhotoCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
uploader = (await db.execute(select(User).where(User.id == payload.uploader_id))).scalar_one_or_none()
|
||
if not uploader:
|
||
raise HTTPException(status_code=404, detail="Uploader not found")
|
||
if payload.spot_id is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
photo = EventPhoto(
|
||
event_id=event_id,
|
||
uploader_id=payload.uploader_id,
|
||
image_url=payload.image_url,
|
||
caption=payload.caption,
|
||
spot_id=payload.spot_id,
|
||
)
|
||
db.add(photo)
|
||
await db.commit()
|
||
await db.refresh(photo)
|
||
return _event_photo_item(photo)
|
||
|
||
|
||
@router.put("/events/{event_id}/photos/{photo_id}", response_model=AdminEventPhotoItem)
|
||
async def admin_update_event_photo(
|
||
event_id: int,
|
||
photo_id: int,
|
||
payload: AdminEventPhotoUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
photo = (
|
||
await db.execute(
|
||
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not photo:
|
||
raise HTTPException(status_code=404, detail="Photo not found")
|
||
|
||
update_data = payload.model_dump(exclude_unset=True)
|
||
if "uploader_id" in update_data and update_data["uploader_id"] is not None:
|
||
uploader_exists = await db.execute(select(User.id).where(User.id == update_data["uploader_id"]))
|
||
if uploader_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Uploader not found")
|
||
if "spot_id" in update_data and update_data["spot_id"] is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
for field, value in update_data.items():
|
||
setattr(photo, field, value)
|
||
|
||
await db.commit()
|
||
await db.refresh(photo)
|
||
return _event_photo_item(photo)
|
||
|
||
|
||
@router.delete("/events/{event_id}/photos/{photo_id}")
|
||
async def admin_delete_event_photo(
|
||
event_id: int,
|
||
photo_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
photo = (
|
||
await db.execute(
|
||
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not photo:
|
||
raise HTTPException(status_code=404, detail="Photo not found")
|
||
await db.delete(photo)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.patch("/events/{event_id}/audit", response_model=AdminEventListItem)
|
||
async def admin_audit_event(
|
||
event_id: int,
|
||
payload: AdminAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
result = await db.execute(select(Event).where(Event.id == event_id))
|
||
event = result.scalar_one_or_none()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
event.audit_status = payload.audit_status
|
||
event.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
|
||
await db.commit()
|
||
await db.refresh(event)
|
||
return AdminEventListItem.model_validate(event)
|
||
|
||
|
||
@router.post("/events/batch-audit")
|
||
async def admin_batch_audit_events(
|
||
payload: AdminBatchAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
ids = list({int(i) for i in payload.ids})
|
||
if not ids:
|
||
raise HTTPException(status_code=400, detail="ids is required")
|
||
rows = (await db.execute(select(Event).where(Event.id.in_(ids)))).scalars().all()
|
||
for item in rows:
|
||
item.audit_status = payload.audit_status
|
||
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
await db.commit()
|
||
return {"code": 0, "updated": len(rows)}
|
||
|
||
|
||
@router.get("/shooting", response_model=PageResponse[AdminShootingListItem])
|
||
async def admin_list_shooting(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
city: str | None = Query(default=None),
|
||
role_needed: str | None = Query(default=None),
|
||
status_filter: str | None = Query(default=None, alias="status"),
|
||
audit_status: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(ShootingRequest.title.ilike(like), ShootingRequest.description.ilike(like)))
|
||
if city:
|
||
conditions.append(ShootingRequest.city == city.strip())
|
||
if role_needed:
|
||
conditions.append(ShootingRequest.role_needed == role_needed.strip())
|
||
if status_filter:
|
||
conditions.append(ShootingRequest.status == status_filter.strip())
|
||
if audit_status:
|
||
conditions.append(ShootingRequest.audit_status == audit_status.strip())
|
||
|
||
total_stmt = select(func.count(ShootingRequest.id))
|
||
list_stmt = select(ShootingRequest)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(ShootingRequest.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
|
||
return PageResponse(total=total, items=[AdminShootingListItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.get("/shooting/{request_id}", response_model=AdminShootingDetailItem)
|
||
async def admin_get_shooting(
|
||
request_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
return _shooting_to_admin_detail(request)
|
||
|
||
|
||
@router.post("/shooting", response_model=AdminShootingDetailItem, status_code=201)
|
||
async def admin_create_shooting(
|
||
payload: AdminShootingCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
creator_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
|
||
if creator_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
if payload.spot_id is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
request = ShootingRequest(
|
||
creator_id=payload.creator_id,
|
||
title=payload.title,
|
||
city=payload.city,
|
||
description=payload.description,
|
||
style=payload.style,
|
||
shoot_date=payload.shoot_date,
|
||
is_free=payload.is_free,
|
||
budget_min=payload.budget_min if not payload.is_free else None,
|
||
budget_max=payload.budget_max if not payload.is_free else None,
|
||
role_needed=payload.role_needed,
|
||
max_applicants=payload.max_applicants,
|
||
contact_info=payload.contact_info,
|
||
spot_id=payload.spot_id,
|
||
status=payload.status,
|
||
audit_status=payload.audit_status,
|
||
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
|
||
)
|
||
db.add(request)
|
||
await db.flush()
|
||
|
||
await _sync_shooting_applications(db, request, [item.model_dump() for item in payload.applications])
|
||
|
||
await db.commit()
|
||
created = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request.id))).scalar_one()
|
||
return _shooting_to_admin_detail(created)
|
||
|
||
|
||
@router.put("/shooting/{request_id}", response_model=AdminShootingDetailItem)
|
||
async def admin_update_shooting(
|
||
request_id: int,
|
||
payload: AdminShootingUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
|
||
update_data = payload.model_dump(exclude_unset=True)
|
||
applications_payload = update_data.pop("applications", None)
|
||
|
||
if "creator_id" in update_data and update_data["creator_id"] is not None:
|
||
creator_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
|
||
if creator_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Creator user not found")
|
||
if "spot_id" in update_data and update_data["spot_id"] is not None:
|
||
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
|
||
if spot_exists.scalar_one_or_none() is None:
|
||
raise HTTPException(status_code=404, detail="Spot not found")
|
||
|
||
if "is_free" in update_data and update_data["is_free"] is True:
|
||
update_data["budget_min"] = None
|
||
update_data["budget_max"] = None
|
||
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
|
||
update_data["reject_reason"] = None
|
||
|
||
for field, value in update_data.items():
|
||
setattr(request, field, value)
|
||
|
||
if applications_payload is not None:
|
||
await _sync_shooting_applications(db, request, applications_payload)
|
||
|
||
await db.commit()
|
||
refreshed = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request.id))).scalar_one()
|
||
return _shooting_to_admin_detail(refreshed)
|
||
|
||
|
||
@router.delete("/shooting/{request_id}")
|
||
async def admin_delete_shooting(
|
||
request_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
await db.execute(sa_delete(ShootingApplication).where(ShootingApplication.request_id == request_id))
|
||
await db.delete(request)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/shooting/{request_id}/applications", response_model=list[AdminShootingApplicationItem])
|
||
async def admin_list_shooting_applications(
|
||
request_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
rows = (
|
||
await db.execute(
|
||
select(ShootingApplication)
|
||
.where(ShootingApplication.request_id == request_id)
|
||
.order_by(ShootingApplication.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
return [_shooting_application_item(item) for item in rows]
|
||
|
||
|
||
@router.post("/shooting/{request_id}/applications", response_model=AdminShootingApplicationItem, status_code=201)
|
||
async def admin_create_shooting_application(
|
||
request_id: int,
|
||
payload: AdminShootingApplicationCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
applicant = (await db.execute(select(User).where(User.id == payload.applicant_id))).scalar_one_or_none()
|
||
if not applicant:
|
||
raise HTTPException(status_code=404, detail="Applicant user not found")
|
||
|
||
exists = (
|
||
await db.execute(
|
||
select(ShootingApplication).where(
|
||
ShootingApplication.request_id == request_id,
|
||
ShootingApplication.applicant_id == payload.applicant_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail="Application already exists")
|
||
|
||
application = ShootingApplication(
|
||
request_id=request_id,
|
||
applicant_id=payload.applicant_id,
|
||
message=payload.message,
|
||
status=payload.status,
|
||
)
|
||
db.add(application)
|
||
await db.commit()
|
||
await db.refresh(application)
|
||
return _shooting_application_item(application)
|
||
|
||
|
||
@router.put("/shooting/{request_id}/applications/{application_id}", response_model=AdminShootingApplicationItem)
|
||
async def admin_update_shooting_application(
|
||
request_id: int,
|
||
application_id: int,
|
||
payload: AdminShootingApplicationUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
application = (
|
||
await db.execute(
|
||
select(ShootingApplication).where(
|
||
ShootingApplication.request_id == request_id,
|
||
ShootingApplication.id == application_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not application:
|
||
raise HTTPException(status_code=404, detail="Application not found")
|
||
|
||
application.message = payload.message
|
||
application.status = payload.status
|
||
await db.commit()
|
||
await db.refresh(application)
|
||
return _shooting_application_item(application)
|
||
|
||
|
||
@router.delete("/shooting/{request_id}/applications/{application_id}")
|
||
async def admin_delete_shooting_application(
|
||
request_id: int,
|
||
application_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
application = (
|
||
await db.execute(
|
||
select(ShootingApplication).where(
|
||
ShootingApplication.request_id == request_id,
|
||
ShootingApplication.id == application_id,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not application:
|
||
raise HTTPException(status_code=404, detail="Application not found")
|
||
await db.delete(application)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.patch("/shooting/{request_id}/audit", response_model=AdminShootingListItem)
|
||
async def admin_audit_shooting(
|
||
request_id: int,
|
||
payload: AdminAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
|
||
request = result.scalar_one_or_none()
|
||
if not request:
|
||
raise HTTPException(status_code=404, detail="Shooting request not found")
|
||
|
||
request.audit_status = payload.audit_status
|
||
request.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
|
||
await db.commit()
|
||
await db.refresh(request)
|
||
return AdminShootingListItem.model_validate(request)
|
||
|
||
|
||
@router.post("/shooting/batch-audit")
|
||
async def admin_batch_audit_shooting(
|
||
payload: AdminBatchAuditRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
ids = list({int(i) for i in payload.ids})
|
||
if not ids:
|
||
raise HTTPException(status_code=400, detail="ids is required")
|
||
rows = (await db.execute(select(ShootingRequest).where(ShootingRequest.id.in_(ids)))).scalars().all()
|
||
for item in rows:
|
||
item.audit_status = payload.audit_status
|
||
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
|
||
await db.commit()
|
||
return {"code": 0, "updated": len(rows)}
|
||
|
||
|
||
@router.get("/app-nav-configs", response_model=PageResponse[AppNavConfigOut])
|
||
async def admin_list_app_nav_configs(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(AppNavConfig.label.ilike(like), AppNavConfig.key.ilike(like)))
|
||
|
||
total_stmt = select(func.count(AppNavConfig.id))
|
||
list_stmt = select(AppNavConfig)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(AppNavConfig.sort_order.asc(), AppNavConfig.id.asc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[AppNavConfigOut.model_validate(i) for i in items])
|
||
|
||
|
||
@router.post("/app-nav-configs", response_model=AppNavConfigOut, status_code=201)
|
||
async def admin_create_app_nav_config(
|
||
payload: AppNavConfigCreate,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
exists = await db.execute(select(AppNavConfig).where(AppNavConfig.key == payload.key))
|
||
if exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="key already exists")
|
||
|
||
item = AppNavConfig(**payload.model_dump())
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AppNavConfigOut.model_validate(item)
|
||
|
||
|
||
@router.put("/app-nav-configs/{item_id}", response_model=AppNavConfigOut)
|
||
async def admin_update_app_nav_config(
|
||
item_id: int,
|
||
payload: AppNavConfigUpdate,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(AppNavConfig).where(AppNavConfig.id == item_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="AppNavConfig not found")
|
||
|
||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||
setattr(item, field, value)
|
||
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AppNavConfigOut.model_validate(item)
|
||
|
||
|
||
@router.delete("/app-nav-configs/{item_id}")
|
||
async def admin_delete_app_nav_config(
|
||
item_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(AppNavConfig).where(AppNavConfig.id == item_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="AppNavConfig not found")
|
||
await db.delete(item)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/promotion-link-options")
|
||
async def admin_promotion_link_options(
|
||
link_type: str = Query(pattern="^(spot|event|shooting)$"),
|
||
keyword: str | None = Query(default=None),
|
||
limit: int = Query(default=20, ge=1, le=100),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
if link_type == "spot":
|
||
stmt = select(Spot.id, Spot.title).order_by(Spot.created_at.desc()).limit(limit)
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
stmt = select(Spot.id, Spot.title).where(Spot.title.ilike(like)).order_by(Spot.created_at.desc()).limit(limit)
|
||
elif link_type == "event":
|
||
stmt = select(Event.id, Event.title).order_by(Event.created_at.desc()).limit(limit)
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
stmt = select(Event.id, Event.title).where(Event.title.ilike(like)).order_by(Event.created_at.desc()).limit(limit)
|
||
else:
|
||
stmt = select(ShootingRequest.id, ShootingRequest.title).order_by(ShootingRequest.created_at.desc()).limit(limit)
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
stmt = select(ShootingRequest.id, ShootingRequest.title).where(ShootingRequest.title.ilike(like)).order_by(ShootingRequest.created_at.desc()).limit(limit)
|
||
|
||
rows = (await db.execute(stmt)).all()
|
||
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
|
||
|
||
|
||
@router.get("/promotions", response_model=PageResponse[PromotionOut])
|
||
async def admin_list_promotions(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
position: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if keyword:
|
||
conditions.append(Promotion.title.ilike(f"%{keyword.strip()}%"))
|
||
if position:
|
||
conditions.append(Promotion.position == position.strip())
|
||
|
||
total_stmt = select(func.count(Promotion.id))
|
||
list_stmt = select(Promotion)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(Promotion.sort_order.asc(), Promotion.id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[PromotionOut.model_validate(i) for i in items])
|
||
|
||
|
||
def _normalize_promotion_relation(payload: dict) -> dict:
|
||
link_type = payload.get("link_type")
|
||
if link_type == "spot":
|
||
payload["spot_id"] = payload.get("spot_id") or payload.get("link_id")
|
||
payload["event_id"] = None
|
||
payload["shooting_id"] = None
|
||
payload["link_url"] = None
|
||
elif link_type == "event":
|
||
payload["event_id"] = payload.get("event_id") or payload.get("link_id")
|
||
payload["spot_id"] = None
|
||
payload["shooting_id"] = None
|
||
payload["link_url"] = None
|
||
elif link_type == "shooting":
|
||
payload["shooting_id"] = payload.get("shooting_id") or payload.get("link_id")
|
||
payload["spot_id"] = None
|
||
payload["event_id"] = None
|
||
payload["link_url"] = None
|
||
elif link_type == "url":
|
||
payload["spot_id"] = None
|
||
payload["event_id"] = None
|
||
payload["shooting_id"] = None
|
||
payload["link_id"] = None
|
||
return payload
|
||
|
||
|
||
@router.post("/promotions", response_model=PromotionOut, status_code=201)
|
||
async def admin_create_promotion(
|
||
payload: PromotionCreate,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
data = _normalize_promotion_relation(payload.model_dump())
|
||
item = Promotion(**data)
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return PromotionOut.model_validate(item)
|
||
|
||
|
||
@router.put("/promotions/{promotion_id}", response_model=PromotionOut)
|
||
async def admin_update_promotion(
|
||
promotion_id: int,
|
||
payload: PromotionUpdate,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Promotion).where(Promotion.id == promotion_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Promotion not found")
|
||
|
||
data = _normalize_promotion_relation(payload.model_dump(exclude_unset=True))
|
||
for field, value in data.items():
|
||
setattr(item, field, value)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return PromotionOut.model_validate(item)
|
||
|
||
|
||
@router.delete("/promotions/{promotion_id}")
|
||
async def admin_delete_promotion(
|
||
promotion_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Promotion).where(Promotion.id == promotion_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Promotion not found")
|
||
await db.delete(item)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/user-options")
|
||
async def admin_user_options(
|
||
keyword: str | None = Query(default=None),
|
||
limit: int = Query(default=20, ge=1, le=100),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
stmt = select(User.id, User.nickname).order_by(User.created_at.desc()).limit(limit)
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
stmt = (
|
||
select(User.id, User.nickname)
|
||
.where(or_(User.nickname.ilike(like), User.phone.ilike(like), User.email.ilike(like)))
|
||
.order_by(User.created_at.desc())
|
||
.limit(limit)
|
||
)
|
||
rows = (await db.execute(stmt)).all()
|
||
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
|
||
|
||
|
||
@router.get("/membership/plans", response_model=PageResponse[AdminMembershipPlanItem])
|
||
async def admin_list_membership_plans(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
keyword: str | None = Query(default=None),
|
||
is_active: bool | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(MembershipPlan.name.ilike(like), MembershipPlan.description.ilike(like)))
|
||
if is_active is not None:
|
||
conditions.append(MembershipPlan.is_active == is_active)
|
||
|
||
total_stmt = select(func.count(MembershipPlan.id))
|
||
list_stmt = select(MembershipPlan)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(MembershipPlan.sort_order.asc(), MembershipPlan.id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[AdminMembershipPlanItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.post("/membership/plans", response_model=AdminMembershipPlanItem, status_code=201)
|
||
async def admin_create_membership_plan(
|
||
payload: AdminMembershipPlanCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
item = MembershipPlan(**payload.model_dump())
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AdminMembershipPlanItem.model_validate(item)
|
||
|
||
|
||
@router.put("/membership/plans/{plan_id}", response_model=AdminMembershipPlanItem)
|
||
async def admin_update_membership_plan(
|
||
plan_id: int,
|
||
payload: AdminMembershipPlanUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(MembershipPlan).where(MembershipPlan.id == plan_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Membership plan not found")
|
||
|
||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||
setattr(item, field, value)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AdminMembershipPlanItem.model_validate(item)
|
||
|
||
|
||
@router.delete("/membership/plans/{plan_id}")
|
||
async def admin_delete_membership_plan(
|
||
plan_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(MembershipPlan).where(MembershipPlan.id == plan_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Membership plan not found")
|
||
item.is_active = False
|
||
await db.commit()
|
||
return {"code": 0, "message": "disabled"}
|
||
|
||
|
||
def _to_admin_user_membership_item(um: UserMembership) -> AdminUserMembershipItem:
|
||
return AdminUserMembershipItem(
|
||
id=um.id,
|
||
user_id=um.user_id,
|
||
user_nickname=um.user.nickname if um.user else "",
|
||
plan_id=um.plan_id,
|
||
plan_name=um.plan.name if um.plan else "",
|
||
start_date=um.start_date,
|
||
end_date=um.end_date,
|
||
is_active=um.is_active,
|
||
)
|
||
|
||
|
||
@router.get("/membership/user-memberships", response_model=PageResponse[AdminUserMembershipItem])
|
||
async def admin_list_user_memberships(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
user_id: int | None = Query(default=None),
|
||
plan_id: int | None = Query(default=None),
|
||
is_active: bool | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if user_id:
|
||
conditions.append(UserMembership.user_id == user_id)
|
||
if plan_id:
|
||
conditions.append(UserMembership.plan_id == plan_id)
|
||
if is_active is not None:
|
||
conditions.append(UserMembership.is_active == is_active)
|
||
|
||
total_stmt = select(func.count(UserMembership.id))
|
||
list_stmt = select(UserMembership)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(UserMembership.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[_to_admin_user_membership_item(i) for i in items])
|
||
|
||
|
||
@router.post("/membership/user-memberships", response_model=AdminUserMembershipItem, status_code=201)
|
||
async def admin_create_user_membership(
|
||
payload: AdminUserMembershipCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
user_exists = await db.execute(select(User.id).where(User.id == payload.user_id))
|
||
if not user_exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
plan_exists = await db.execute(select(MembershipPlan.id).where(MembershipPlan.id == payload.plan_id))
|
||
if not plan_exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=404, detail="Membership plan not found")
|
||
|
||
item = UserMembership(**payload.model_dump())
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_user_membership_item(item)
|
||
|
||
|
||
@router.put("/membership/user-memberships/{membership_id}", response_model=AdminUserMembershipItem)
|
||
async def admin_update_user_membership(
|
||
membership_id: int,
|
||
payload: AdminUserMembershipUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(UserMembership).where(UserMembership.id == membership_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="User membership not found")
|
||
|
||
data = payload.model_dump(exclude_unset=True)
|
||
plan_id = data.get("plan_id")
|
||
if plan_id:
|
||
plan_exists = await db.execute(select(MembershipPlan.id).where(MembershipPlan.id == plan_id))
|
||
if not plan_exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=404, detail="Membership plan not found")
|
||
for field, value in data.items():
|
||
setattr(item, field, value)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_user_membership_item(item)
|
||
|
||
|
||
@router.delete("/membership/user-memberships/{membership_id}")
|
||
async def admin_delete_user_membership(
|
||
membership_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(UserMembership).where(UserMembership.id == membership_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="User membership not found")
|
||
item.is_active = False
|
||
await db.commit()
|
||
return {"code": 0, "message": "disabled"}
|
||
|
||
|
||
def _to_admin_point_item(item: PointLedger, *, rolled_back: bool = False) -> AdminPointLedgerItem:
|
||
return AdminPointLedgerItem(
|
||
id=item.id,
|
||
user_id=item.user_id,
|
||
user_nickname=item.user.nickname if item.user else "",
|
||
change=item.change,
|
||
balance=item.balance,
|
||
reason=item.reason,
|
||
ref_type=item.ref_type,
|
||
ref_id=item.ref_id,
|
||
rolled_back=rolled_back,
|
||
created_at=item.created_at,
|
||
)
|
||
|
||
|
||
def _to_admin_notification_item(item: Notification) -> AdminNotificationItem:
|
||
return AdminNotificationItem(
|
||
id=item.id,
|
||
user_id=item.user_id,
|
||
user_nickname=item.user.nickname if item.user else "",
|
||
type=item.type,
|
||
title=item.title,
|
||
content=item.content,
|
||
ref_type=item.ref_type,
|
||
ref_id=item.ref_id,
|
||
is_read=item.is_read,
|
||
created_at=item.created_at,
|
||
)
|
||
|
||
|
||
def _to_admin_audit_log_item(item: AuditLog) -> AdminAuditLogItem:
|
||
return AdminAuditLogItem(
|
||
id=item.id,
|
||
operator_id=item.operator_id,
|
||
operator_nickname=item.operator.nickname if item.operator else "",
|
||
action=item.action,
|
||
target_type=item.target_type,
|
||
target_id=item.target_id,
|
||
detail=item.detail,
|
||
created_at=item.created_at,
|
||
)
|
||
|
||
|
||
def _to_admin_report_item(item: Report) -> AdminReportItem:
|
||
return AdminReportItem(
|
||
id=item.id,
|
||
reporter_id=item.reporter_id,
|
||
reporter_nickname=item.reporter.nickname if item.reporter else "",
|
||
target_type=item.target_type,
|
||
target_id=item.target_id,
|
||
reason=item.reason,
|
||
status=item.status,
|
||
handler_id=item.handler_id,
|
||
handler_nickname=item.handler.nickname if item.handler else None,
|
||
conclusion=item.conclusion,
|
||
created_at=item.created_at,
|
||
resolved_at=item.resolved_at,
|
||
)
|
||
|
||
|
||
@router.get("/points/ledger", response_model=PageResponse[AdminPointLedgerItem])
|
||
async def admin_list_point_ledger(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
user_id: int | None = Query(default=None),
|
||
reason: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if user_id:
|
||
conditions.append(PointLedger.user_id == user_id)
|
||
if reason:
|
||
conditions.append(PointLedger.reason.ilike(f"%{reason.strip()}%"))
|
||
|
||
total_stmt = select(func.count(PointLedger.id))
|
||
list_stmt = select(PointLedger)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(PointLedger.id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
item_ids = [item.id for item in items]
|
||
rolled_back_ids: set[int] = set()
|
||
if item_ids:
|
||
rolled_back_rows = (
|
||
await db.execute(
|
||
select(PointLedger.ref_id).where(
|
||
PointLedger.ref_type == "points_rollback",
|
||
PointLedger.ref_id.in_(item_ids),
|
||
)
|
||
)
|
||
).scalars().all()
|
||
rolled_back_ids = {int(i) for i in rolled_back_rows if i is not None}
|
||
return PageResponse(
|
||
total=total,
|
||
items=[_to_admin_point_item(i, rolled_back=i.id in rolled_back_ids) for i in items],
|
||
)
|
||
|
||
|
||
@router.post("/points/adjust", response_model=AdminPointLedgerItem, status_code=201)
|
||
async def admin_adjust_points(
|
||
payload: AdminPointAdjustRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
user_result = await db.execute(select(User).where(User.id == payload.user_id))
|
||
user = user_result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
last_result = await db.execute(
|
||
select(PointLedger).where(PointLedger.user_id == payload.user_id).order_by(PointLedger.id.desc()).limit(1)
|
||
)
|
||
last = last_result.scalar_one_or_none()
|
||
last_balance = last.balance if last else 0
|
||
new_balance = last_balance + payload.change
|
||
|
||
item = PointLedger(
|
||
user_id=payload.user_id,
|
||
change=payload.change,
|
||
balance=new_balance,
|
||
reason=payload.reason,
|
||
ref_type=payload.ref_type,
|
||
ref_id=payload.ref_id,
|
||
)
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_point_item(item)
|
||
|
||
|
||
@router.post("/points/ledger/{ledger_id}/rollback", response_model=AdminPointLedgerItem, status_code=201)
|
||
async def admin_rollback_point_ledger(
|
||
ledger_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
|
||
target_result = await db.execute(select(PointLedger).where(PointLedger.id == ledger_id))
|
||
target = target_result.scalar_one_or_none()
|
||
if not target:
|
||
raise HTTPException(status_code=404, detail="Point ledger not found")
|
||
if target.ref_type == "points_rollback":
|
||
raise HTTPException(status_code=400, detail="Rollback ledger cannot be rolled back")
|
||
|
||
exists_rollback = await db.execute(
|
||
select(PointLedger.id).where(
|
||
PointLedger.ref_type == "points_rollback",
|
||
PointLedger.ref_id == ledger_id,
|
||
)
|
||
)
|
||
if exists_rollback.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="This ledger has already been rolled back")
|
||
|
||
last_result = await db.execute(
|
||
select(PointLedger).where(PointLedger.user_id == target.user_id).order_by(PointLedger.id.desc()).limit(1)
|
||
)
|
||
last = last_result.scalar_one_or_none()
|
||
last_balance = last.balance if last else 0
|
||
rollback_change = -target.change
|
||
new_balance = last_balance + rollback_change
|
||
|
||
rollback_item = PointLedger(
|
||
user_id=target.user_id,
|
||
change=rollback_change,
|
||
balance=new_balance,
|
||
reason=f"回滚流水#{ledger_id}:{target.reason}",
|
||
ref_type="points_rollback",
|
||
ref_id=ledger_id,
|
||
)
|
||
db.add(rollback_item)
|
||
await db.commit()
|
||
await db.refresh(rollback_item)
|
||
return _to_admin_point_item(rollback_item, rolled_back=False)
|
||
|
||
|
||
@router.get("/notifications", response_model=PageResponse[AdminNotificationItem])
|
||
async def admin_list_notifications(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
user_id: int | None = Query(default=None),
|
||
type: str | None = Query(default=None),
|
||
is_read: bool | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if user_id:
|
||
conditions.append(Notification.user_id == user_id)
|
||
if type:
|
||
conditions.append(Notification.type == type.strip())
|
||
if is_read is not None:
|
||
conditions.append(Notification.is_read == is_read)
|
||
|
||
total_stmt = select(func.count(Notification.id))
|
||
list_stmt = select(Notification)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(Notification.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[_to_admin_notification_item(i) for i in items])
|
||
|
||
|
||
@router.post("/notifications", response_model=AdminNotificationItem, status_code=201)
|
||
async def admin_create_notification(
|
||
payload: AdminNotificationCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
user_exists = await db.execute(select(User.id).where(User.id == payload.user_id))
|
||
if not user_exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
|
||
item = Notification(**payload.model_dump(), is_read=False)
|
||
db.add(item)
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_notification_create",
|
||
target_type="notification",
|
||
target_id=None,
|
||
detail=f"user_id={payload.user_id}, title={payload.title}",
|
||
)
|
||
)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_notification_item(item)
|
||
|
||
|
||
@router.put("/notifications/{notification_id}", response_model=AdminNotificationItem)
|
||
async def admin_update_notification(
|
||
notification_id: int,
|
||
payload: AdminNotificationUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Notification).where(Notification.id == notification_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Notification not found")
|
||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||
setattr(item, field, value)
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_notification_update",
|
||
target_type="notification",
|
||
target_id=item.id,
|
||
detail="updated",
|
||
)
|
||
)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_notification_item(item)
|
||
|
||
|
||
@router.delete("/notifications/{notification_id}")
|
||
async def admin_delete_notification(
|
||
notification_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Notification).where(Notification.id == notification_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Notification not found")
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_notification_delete",
|
||
target_type="notification",
|
||
target_id=item.id,
|
||
detail="deleted",
|
||
)
|
||
)
|
||
await db.delete(item)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/audit-logs", response_model=PageResponse[AdminAuditLogItem])
|
||
async def admin_list_audit_logs(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
operator_id: int | None = Query(default=None),
|
||
action: str | None = Query(default=None),
|
||
target_type: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if operator_id:
|
||
conditions.append(AuditLog.operator_id == operator_id)
|
||
if action:
|
||
conditions.append(AuditLog.action == action.strip())
|
||
if target_type:
|
||
conditions.append(AuditLog.target_type == target_type.strip())
|
||
|
||
total_stmt = select(func.count(AuditLog.id))
|
||
list_stmt = select(AuditLog)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(AuditLog.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[_to_admin_audit_log_item(i) for i in items])
|
||
|
||
|
||
@router.post("/audit-logs", response_model=AdminAuditLogItem, status_code=201)
|
||
async def admin_create_audit_log(
|
||
payload: AdminAuditLogCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
item = AuditLog(
|
||
operator_id=current_user.id,
|
||
action=payload.action,
|
||
target_type=payload.target_type,
|
||
target_id=payload.target_id,
|
||
detail=payload.detail,
|
||
)
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_audit_log_item(item)
|
||
|
||
|
||
@router.get("/reports", response_model=PageResponse[AdminReportItem])
|
||
async def admin_list_reports(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
status_filter: str | None = Query(default=None, alias="status"),
|
||
target_type: str | None = Query(default=None),
|
||
reporter_id: int | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if status_filter:
|
||
conditions.append(Report.status == status_filter.strip())
|
||
if target_type:
|
||
conditions.append(Report.target_type == target_type.strip())
|
||
if reporter_id:
|
||
conditions.append(Report.reporter_id == reporter_id)
|
||
|
||
total_stmt = select(func.count(Report.id))
|
||
list_stmt = select(Report)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(Report.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[_to_admin_report_item(i) for i in items])
|
||
|
||
|
||
@router.put("/reports/{report_id}", response_model=AdminReportItem)
|
||
async def admin_update_report(
|
||
report_id: int,
|
||
payload: AdminReportUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Report).where(Report.id == report_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Report not found")
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "status" in data and data["status"] in ("resolved", "rejected"):
|
||
data["resolved_at"] = datetime.now(timezone.utc)
|
||
if "status" in data and data["status"] == "processing" and "handler_id" not in data:
|
||
data["handler_id"] = current_user.id
|
||
for field, value in data.items():
|
||
setattr(item, field, value)
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_report_update",
|
||
target_type="report",
|
||
target_id=item.id,
|
||
detail=f"status={item.status}",
|
||
)
|
||
)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return _to_admin_report_item(item)
|
||
|
||
|
||
@router.delete("/reports/{report_id}")
|
||
async def admin_delete_report(
|
||
report_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(Report).where(Report.id == report_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="Report not found")
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_report_delete",
|
||
target_type="report",
|
||
target_id=item.id,
|
||
detail="deleted",
|
||
)
|
||
)
|
||
await db.delete(item)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|
||
|
||
|
||
@router.get("/system-configs", response_model=PageResponse[AdminSystemConfigItem])
|
||
async def admin_list_system_configs(
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
category: str | None = Query(default=None),
|
||
keyword: str | None = Query(default=None),
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
conditions = []
|
||
if category:
|
||
conditions.append(SystemConfig.category == category.strip())
|
||
if keyword:
|
||
like = f"%{keyword.strip()}%"
|
||
conditions.append(or_(SystemConfig.title.ilike(like), SystemConfig.config_key.ilike(like)))
|
||
|
||
total_stmt = select(func.count(SystemConfig.id))
|
||
list_stmt = select(SystemConfig)
|
||
if conditions:
|
||
total_stmt = total_stmt.where(*conditions)
|
||
list_stmt = list_stmt.where(*conditions)
|
||
total = (await db.execute(total_stmt)).scalar() or 0
|
||
items = (
|
||
await db.execute(
|
||
list_stmt
|
||
.order_by(SystemConfig.sort_order.asc(), SystemConfig.id.asc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return PageResponse(total=total, items=[AdminSystemConfigItem.model_validate(i) for i in items])
|
||
|
||
|
||
@router.post("/system-configs", response_model=AdminSystemConfigItem, status_code=201)
|
||
async def admin_create_system_config(
|
||
payload: AdminSystemConfigCreateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
exists = await db.execute(select(SystemConfig.id).where(SystemConfig.config_key == payload.config_key))
|
||
if exists.scalar_one_or_none():
|
||
raise HTTPException(status_code=409, detail="config_key already exists")
|
||
item = SystemConfig(**payload.model_dump(), updated_by=current_user.id)
|
||
db.add(item)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AdminSystemConfigItem.model_validate(item)
|
||
|
||
|
||
@router.put("/system-configs/{config_id}", response_model=AdminSystemConfigItem)
|
||
async def admin_update_system_config(
|
||
config_id: int,
|
||
payload: AdminSystemConfigUpdateRequest,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(SystemConfig).where(SystemConfig.id == config_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="System config not found")
|
||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||
setattr(item, field, value)
|
||
item.updated_by = current_user.id
|
||
db.add(
|
||
AuditLog(
|
||
operator_id=current_user.id,
|
||
action="admin_system_config_update",
|
||
target_type="system_config",
|
||
target_id=item.id,
|
||
detail=f"key={item.config_key}",
|
||
)
|
||
)
|
||
await db.commit()
|
||
await db.refresh(item)
|
||
return AdminSystemConfigItem.model_validate(item)
|
||
|
||
|
||
@router.delete("/system-configs/{config_id}")
|
||
async def admin_delete_system_config(
|
||
config_id: int,
|
||
current_user: User = Depends(get_current_active_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_assert_admin_role(current_user)
|
||
result = await db.execute(select(SystemConfig).where(SystemConfig.id == config_id))
|
||
item = result.scalar_one_or_none()
|
||
if not item:
|
||
raise HTTPException(status_code=404, detail="System config not found")
|
||
await db.delete(item)
|
||
await db.commit()
|
||
return {"code": 0, "message": "deleted"}
|