first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -0,0 +1,196 @@
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.deps import AdminUser, DbSession
|
||||
from app.models import Cloud, Profile, User
|
||||
from app.schemas import (
|
||||
AdminCloudStatusIn,
|
||||
AdminCloudVisibilityIn,
|
||||
AdminStatsOut,
|
||||
AdminUserOut,
|
||||
AdminUserUpdateIn,
|
||||
BatchIdsIn,
|
||||
BatchUpdateOut,
|
||||
CloudOut,
|
||||
DeleteResultOut,
|
||||
PageOut,
|
||||
)
|
||||
from app.serializers import cloud_out, profile_out, user_out
|
||||
from app.services.storage import delete_files
|
||||
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminStatsOut)
|
||||
async def get_stats(_: AdminUser, db: DbSession) -> AdminStatsOut:
|
||||
local_tz = ZoneInfo("Asia/Shanghai")
|
||||
local_now = datetime.now(local_tz)
|
||||
today_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||
users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count(Cloud.id).label("images"),
|
||||
func.sum(case((Cloud.created_at >= today_start, 1), else_=0)).label("today"),
|
||||
func.sum(case((Cloud.status == "pending", 1), else_=0)).label("pending"),
|
||||
func.sum(case((Cloud.status == "approved", 1), else_=0)).label("approved"),
|
||||
func.sum(case((Cloud.status == "rejected", 1), else_=0)).label("rejected"),
|
||||
func.sum(case((Cloud.is_hidden.is_(True), 1), else_=0)).label("hidden"),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return AdminStatsOut(
|
||||
users=users,
|
||||
images=row.images or 0,
|
||||
today_uploads=row.today or 0,
|
||||
pending=row.pending or 0,
|
||||
approved=row.approved or 0,
|
||||
rejected=row.rejected or 0,
|
||||
hidden=row.hidden or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_model=PageOut[AdminUserOut])
|
||||
async def list_users(
|
||||
_: AdminUser,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=100),
|
||||
) -> PageOut[AdminUserOut]:
|
||||
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.profile))
|
||||
.order_by(User.created_at.desc(), User.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) for user in result.scalars()]
|
||||
return PageOut(
|
||||
items=items,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
payload: AdminUserUpdateIn,
|
||||
admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> AdminUserOut:
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.profile)).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.id == admin.id and (
|
||||
payload.is_disabled is True or (payload.role is not None and payload.role != "admin")
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
|
||||
if payload.role is not None:
|
||||
user.profile.role = payload.role
|
||||
if payload.is_disabled is not None:
|
||||
user.profile.is_disabled = payload.is_disabled
|
||||
await db.commit()
|
||||
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
|
||||
|
||||
|
||||
@router.get("/clouds", response_model=PageOut[CloudOut])
|
||||
async def list_admin_clouds(
|
||||
_: AdminUser,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=120),
|
||||
cloud_status: Literal["pending", "approved", "rejected"] | None = Query(None, alias="status"),
|
||||
is_hidden: bool | None = None,
|
||||
) -> PageOut[CloudOut]:
|
||||
filters = []
|
||||
if cloud_status:
|
||||
filters.append(Cloud.status == cloud_status)
|
||||
if is_hidden is not None:
|
||||
filters.append(Cloud.is_hidden == is_hidden)
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return PageOut(
|
||||
items=[cloud_out(item) for item in result.scalars().unique().all()],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
async def _require_all_cloud_ids(db: DbSession, ids: list[uuid.UUID]) -> None:
|
||||
count = await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.id.in_(ids))) or 0
|
||||
if count != len(ids):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
||||
|
||||
|
||||
@router.patch("/clouds/status", response_model=BatchUpdateOut)
|
||||
async def update_cloud_status(
|
||||
payload: AdminCloudStatusIn,
|
||||
_: AdminUser,
|
||||
db: DbSession,
|
||||
) -> BatchUpdateOut:
|
||||
await _require_all_cloud_ids(db, payload.ids)
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id.in_(payload.ids))
|
||||
.values(status=payload.status, updated_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
return BatchUpdateOut(updated=len(payload.ids))
|
||||
|
||||
|
||||
@router.patch("/clouds/visibility", response_model=BatchUpdateOut)
|
||||
async def update_cloud_visibility(
|
||||
payload: AdminCloudVisibilityIn,
|
||||
_: AdminUser,
|
||||
db: DbSession,
|
||||
) -> BatchUpdateOut:
|
||||
await _require_all_cloud_ids(db, payload.ids)
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id.in_(payload.ids))
|
||||
.values(is_hidden=payload.is_hidden, updated_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
return BatchUpdateOut(updated=len(payload.ids))
|
||||
|
||||
|
||||
@router.post("/clouds/batch-delete", response_model=DeleteResultOut)
|
||||
async def delete_admin_clouds(
|
||||
payload: BatchIdsIn,
|
||||
_: AdminUser,
|
||||
db: DbSession,
|
||||
) -> DeleteResultOut:
|
||||
result = await db.execute(select(Cloud).where(Cloud.id.in_(payload.ids)))
|
||||
clouds = result.scalars().all()
|
||||
if len(clouds) != len(payload.ids):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
||||
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||
for cloud in clouds:
|
||||
await db.delete(cloud)
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=len(clouds))
|
||||
@@ -0,0 +1,343 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import CurrentUser, DbSession
|
||||
from app.models import EmailToken, Profile, RefreshSession, User
|
||||
from app.schemas import (
|
||||
AuthOut,
|
||||
ChangePasswordIn,
|
||||
ForgotPasswordIn,
|
||||
LoginIn,
|
||||
MeOut,
|
||||
MessageOut,
|
||||
RegisterIn,
|
||||
ResetPasswordIn,
|
||||
TokenIn,
|
||||
)
|
||||
from app.security import (
|
||||
DUMMY_PASSWORD_HASH,
|
||||
create_access_token,
|
||||
create_random_token,
|
||||
hash_password,
|
||||
hash_token,
|
||||
refresh_expires_at,
|
||||
utc_now,
|
||||
verify_password,
|
||||
)
|
||||
from app.serializers import profile_out, user_out
|
||||
from app.services.email import send_confirmation_email, send_password_reset_email
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _aware_utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _client_metadata(request: Request) -> tuple[str | None, str | None]:
|
||||
user_agent = request.headers.get("user-agent")
|
||||
ip_address = request.client.host if request.client else None
|
||||
return user_agent, ip_address
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=settings.refresh_cookie_name,
|
||||
value=token,
|
||||
max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite=settings.cookie_samesite,
|
||||
domain=settings.cookie_domain,
|
||||
path=f"{settings.api_v1_prefix}/auth",
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=settings.refresh_cookie_name,
|
||||
domain=settings.cookie_domain,
|
||||
path=f"{settings.api_v1_prefix}/auth",
|
||||
secure=settings.cookie_secure,
|
||||
httponly=True,
|
||||
samesite=settings.cookie_samesite,
|
||||
)
|
||||
|
||||
|
||||
def _auth_response(user: User, session: RefreshSession) -> AuthOut:
|
||||
return AuthOut(
|
||||
access_token=create_access_token(user.id, session.id),
|
||||
expires_in=settings.access_token_expire_minutes * 60,
|
||||
user=user_out(user),
|
||||
profile=profile_out(user.profile),
|
||||
)
|
||||
|
||||
|
||||
def _new_session(user: User, request: Request) -> tuple[RefreshSession, str]:
|
||||
token = create_random_token()
|
||||
user_agent, ip_address = _client_metadata(request)
|
||||
session = RefreshSession(
|
||||
user_id=user.id,
|
||||
token_hash=hash_token(token),
|
||||
expires_at=refresh_expires_at(),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
return session, token
|
||||
|
||||
|
||||
async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
|
||||
now = utc_now()
|
||||
await db.execute(
|
||||
update(EmailToken)
|
||||
.where(
|
||||
EmailToken.user_id == user.id,
|
||||
EmailToken.purpose == purpose,
|
||||
EmailToken.used_at.is_(None),
|
||||
)
|
||||
.values(used_at=now)
|
||||
)
|
||||
token = create_random_token()
|
||||
db.add(
|
||||
EmailToken(
|
||||
user_id=user.id,
|
||||
purpose=purpose,
|
||||
token_hash=hash_token(token),
|
||||
expires_at=now + expires_delta,
|
||||
)
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
|
||||
email = str(payload.email).strip().lower()
|
||||
user = User(email=email, password_hash=hash_password(payload.password))
|
||||
user.profile = Profile(username=payload.username)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.flush()
|
||||
token = await _create_email_token(
|
||||
db,
|
||||
user,
|
||||
"confirm_email",
|
||||
timedelta(hours=settings.email_confirmation_expire_hours),
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
message = str(exc.orig).lower()
|
||||
if "email" in message:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||
|
||||
try:
|
||||
await send_confirmation_email(user.email, token)
|
||||
except Exception:
|
||||
logger.exception("注册成功,但确认邮件发送失败:%s", user.email)
|
||||
return MessageOut(message="注册成功,请查收确认邮件")
|
||||
|
||||
|
||||
@router.post("/resend-confirmation", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def resend_confirmation(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
|
||||
email = str(payload.email).strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and user.email_verified_at is None:
|
||||
token = await _create_email_token(
|
||||
db,
|
||||
user,
|
||||
"confirm_email",
|
||||
timedelta(hours=settings.email_confirmation_expire_hours),
|
||||
)
|
||||
await db.commit()
|
||||
try:
|
||||
await send_confirmation_email(user.email, token)
|
||||
except Exception:
|
||||
logger.exception("确认邮件重发失败:%s", user.email)
|
||||
return MessageOut(message="如果账号存在且尚未确认,确认邮件将会发送")
|
||||
|
||||
|
||||
@router.post("/confirm-email", response_model=MessageOut)
|
||||
async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
|
||||
now = utc_now()
|
||||
result = await db.execute(
|
||||
select(EmailToken)
|
||||
.where(
|
||||
EmailToken.token_hash == hash_token(payload.token),
|
||||
EmailToken.purpose == "confirm_email",
|
||||
EmailToken.used_at.is_(None),
|
||||
EmailToken.expires_at > now,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
email_token = result.scalar_one_or_none()
|
||||
if not email_token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="确认链接无效或已过期")
|
||||
user = await db.get(User, email_token.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
|
||||
user.email_verified_at = user.email_verified_at or now
|
||||
email_token.used_at = now
|
||||
await db.commit()
|
||||
return MessageOut(message="邮箱确认成功")
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthOut)
|
||||
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
|
||||
email = str(payload.email).strip().lower()
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.profile)).where(User.email == email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
|
||||
password_valid = verify_password(payload.password, encoded_hash)
|
||||
if not user or not password_valid:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
if user.email_verified_at is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认")
|
||||
if user.profile.is_disabled:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||
|
||||
session, refresh_token = _new_session(user, request)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return _auth_response(user, session)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AuthOut)
|
||||
async def refresh(request: Request, response: Response, db: DbSession) -> AuthOut:
|
||||
raw_token = request.cookies.get(settings.refresh_cookie_name)
|
||||
if not raw_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少刷新令牌")
|
||||
now = utc_now()
|
||||
result = await db.execute(
|
||||
select(RefreshSession)
|
||||
.options(selectinload(RefreshSession.user).selectinload(User.profile))
|
||||
.where(RefreshSession.token_hash == hash_token(raw_token))
|
||||
.with_for_update()
|
||||
)
|
||||
old_session = result.scalar_one_or_none()
|
||||
if (
|
||||
not old_session
|
||||
or old_session.revoked_at is not None
|
||||
or _aware_utc(old_session.expires_at) <= now
|
||||
):
|
||||
_clear_refresh_cookie(response)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
|
||||
user = old_session.user
|
||||
if user.profile.is_disabled:
|
||||
old_session.revoked_at = now
|
||||
await db.commit()
|
||||
_clear_refresh_cookie(response)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||
|
||||
new_session, new_token = _new_session(user, request)
|
||||
db.add(new_session)
|
||||
await db.flush()
|
||||
old_session.revoked_at = now
|
||||
old_session.replaced_by_id = new_session.id
|
||||
await db.commit()
|
||||
_set_refresh_cookie(response, new_token)
|
||||
return _auth_response(user, new_session)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageOut)
|
||||
async def logout(request: Request, response: Response, db: DbSession) -> MessageOut:
|
||||
raw_token = request.cookies.get(settings.refresh_cookie_name)
|
||||
if raw_token:
|
||||
result = await db.execute(
|
||||
select(RefreshSession).where(RefreshSession.token_hash == hash_token(raw_token))
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session and session.revoked_at is None:
|
||||
session.revoked_at = utc_now()
|
||||
await db.commit()
|
||||
_clear_refresh_cookie(response)
|
||||
return MessageOut(message="已退出登录")
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeOut)
|
||||
async def me(user: CurrentUser) -> MeOut:
|
||||
return MeOut(user=user_out(user), profile=profile_out(user.profile))
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
|
||||
email = str(payload.email).strip().lower()
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user and user.email_verified_at is not None:
|
||||
token = await _create_email_token(
|
||||
db,
|
||||
user,
|
||||
"reset_password",
|
||||
timedelta(minutes=settings.password_reset_expire_minutes),
|
||||
)
|
||||
await db.commit()
|
||||
try:
|
||||
await send_password_reset_email(user.email, token)
|
||||
except Exception:
|
||||
logger.exception("密码重置邮件发送失败:%s", user.email)
|
||||
return MessageOut(message="如果该邮箱已经注册,密码重置邮件将会发送")
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageOut)
|
||||
async def reset_password(payload: ResetPasswordIn, db: DbSession) -> MessageOut:
|
||||
now = utc_now()
|
||||
result = await db.execute(
|
||||
select(EmailToken)
|
||||
.where(
|
||||
EmailToken.token_hash == hash_token(payload.token),
|
||||
EmailToken.purpose == "reset_password",
|
||||
EmailToken.used_at.is_(None),
|
||||
EmailToken.expires_at > now,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
email_token = result.scalar_one_or_none()
|
||||
if not email_token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||
user = await db.get(User, email_token.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
|
||||
user.password_hash = hash_password(payload.password)
|
||||
email_token.used_at = now
|
||||
await db.execute(
|
||||
update(RefreshSession)
|
||||
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
|
||||
.values(revoked_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
return MessageOut(message="密码已重置,请重新登录")
|
||||
|
||||
|
||||
@router.patch("/password", response_model=AuthOut)
|
||||
async def change_password(
|
||||
payload: ChangePasswordIn,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> AuthOut:
|
||||
now = utc_now()
|
||||
user.password_hash = hash_password(payload.password)
|
||||
await db.execute(
|
||||
update(RefreshSession)
|
||||
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
|
||||
.values(revoked_at=now)
|
||||
)
|
||||
new_session, refresh_token = _new_session(user, request)
|
||||
db.add(new_session)
|
||||
await db.commit()
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return _auth_response(user, new_session)
|
||||
@@ -0,0 +1,59 @@
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import DbSession
|
||||
from app.models import Cloud, CloudType, User
|
||||
from app.schemas import CloudOut, CloudTypeOut, PageOut
|
||||
from app.serializers import cloud_out, cloud_type_out
|
||||
|
||||
|
||||
router = APIRouter(prefix="/cloud-types", tags=["云类型"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[CloudTypeOut])
|
||||
async def list_cloud_types(db: DbSession) -> list[CloudTypeOut]:
|
||||
result = await db.execute(select(CloudType).order_by(CloudType.id))
|
||||
return [cloud_type_out(item) for item in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{cloud_type_id}", response_model=CloudTypeOut)
|
||||
async def get_cloud_type(cloud_type_id: int, db: DbSession) -> CloudTypeOut:
|
||||
cloud_type = await db.get(CloudType, cloud_type_id)
|
||||
if not cloud_type:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
|
||||
return cloud_type_out(cloud_type)
|
||||
|
||||
|
||||
@router.get("/{cloud_type_id}/clouds", response_model=PageOut[CloudOut])
|
||||
async def get_cloud_type_clouds(
|
||||
cloud_type_id: int,
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(24, ge=1, le=100),
|
||||
) -> PageOut[CloudOut]:
|
||||
if not await db.get(CloudType, cloud_type_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
|
||||
filters = [
|
||||
Cloud.cloud_type_id == cloud_type_id,
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
]
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return PageOut(
|
||||
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
@@ -0,0 +1,326 @@
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||
from app.models import Cloud, CloudType, Profile, User, UserCollection
|
||||
from app.schemas import (
|
||||
BadgeOut,
|
||||
BatchIdsIn,
|
||||
CloudCreateOut,
|
||||
CloudOut,
|
||||
CloudUpdateIn,
|
||||
DeleteResultOut,
|
||||
PageOut,
|
||||
)
|
||||
from app.serializers import cloud_out
|
||||
from app.services.storage import delete_files, save_cloud_image
|
||||
|
||||
|
||||
router = APIRouter(prefix="/clouds", tags=["云图"])
|
||||
|
||||
|
||||
def _cloud_options():
|
||||
return (
|
||||
joinedload(Cloud.user).joinedload(User.profile),
|
||||
joinedload(Cloud.cloud_type),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _blur_coordinate(value: float | None) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_required_datetime(value: datetime) -> datetime:
|
||||
return _normalize_datetime(value) or value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _require_cloud_type(db: DbSession, cloud_type_id: int) -> CloudType:
|
||||
cloud_type = await db.get(CloudType, cloud_type_id)
|
||||
if not cloud_type:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="云类型不存在")
|
||||
return cloud_type
|
||||
|
||||
|
||||
async def _get_cloud(db: DbSession, cloud_id: uuid.UUID) -> Cloud | None:
|
||||
result = await db.execute(
|
||||
select(Cloud).options(*_cloud_options()).where(Cloud.id == cloud_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("", response_model=PageOut[CloudOut])
|
||||
async def list_gallery_clouds(
|
||||
db: DbSession,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
type_id: int | None = Query(None, ge=1),
|
||||
search: str | None = Query(None, max_length=80),
|
||||
) -> PageOut[CloudOut]:
|
||||
filters = [Cloud.status == "approved", Cloud.is_hidden.is_(False)]
|
||||
if type_id is not None:
|
||||
filters.append(Cloud.cloud_type_id == type_id)
|
||||
|
||||
data_query = select(Cloud).options(*_cloud_options())
|
||||
count_query = select(func.count(Cloud.id))
|
||||
term = _normalize_text(search)
|
||||
if term and term.startswith("@"):
|
||||
username = term[1:].strip()
|
||||
if not username:
|
||||
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
data_query = data_query.join(Cloud.user).join(User.profile)
|
||||
count_query = count_query.join(Cloud.user).join(User.profile)
|
||||
filters.append(Profile.username.ilike(f"%{username}%"))
|
||||
elif term:
|
||||
data_query = data_query.outerjoin(Cloud.cloud_type)
|
||||
count_query = count_query.outerjoin(Cloud.cloud_type)
|
||||
filters.append(
|
||||
or_(
|
||||
CloudType.name.ilike(f"%{term}%"),
|
||||
CloudType.name_en.ilike(f"%{term}%"),
|
||||
Cloud.custom_cloud_type.ilike(f"%{term}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = await db.scalar(count_query.where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
data_query.where(*filters)
|
||||
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return PageOut(
|
||||
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/map", response_model=list[CloudOut])
|
||||
async def list_map_clouds(
|
||||
db: DbSession,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
time_field: Literal["captured_at", "created_at"] = "captured_at",
|
||||
limit: int = Query(1000, ge=1, le=1000),
|
||||
) -> list[CloudOut]:
|
||||
start = _normalize_required_datetime(start)
|
||||
end = _normalize_required_datetime(end)
|
||||
if start >= end:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="结束时间必须晚于开始时间")
|
||||
field = Cloud.captured_at if time_field == "captured_at" else Cloud.created_at
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(*_cloud_options())
|
||||
.where(
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
Cloud.latitude.is_not(None),
|
||||
Cloud.longitude.is_not(None),
|
||||
field >= start,
|
||||
field < end,
|
||||
)
|
||||
.order_by(field.asc(), Cloud.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [cloud_out(item, include_private=False) for item in result.scalars().unique().all()]
|
||||
|
||||
|
||||
@router.get("/{cloud_id}", response_model=CloudOut)
|
||||
async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) -> CloudOut:
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin"))
|
||||
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
return cloud_out(cloud, include_private=can_manage)
|
||||
|
||||
|
||||
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_cloud(
|
||||
db: DbSession,
|
||||
user: CurrentUser,
|
||||
image: Annotated[UploadFile, File()],
|
||||
cloud_type_id: Annotated[int | None, Form()] = None,
|
||||
custom_cloud_type: Annotated[str | None, Form(max_length=80)] = None,
|
||||
latitude: Annotated[float | None, Form(ge=-90, le=90)] = None,
|
||||
longitude: Annotated[float | None, Form(ge=-180, le=180)] = None,
|
||||
location_name: Annotated[str | None, Form(max_length=120)] = None,
|
||||
description: Annotated[str | None, Form(max_length=2000)] = None,
|
||||
captured_at: Annotated[datetime | None, Form()] = None,
|
||||
is_hidden: Annotated[bool, Form()] = False,
|
||||
) -> CloudCreateOut:
|
||||
custom_cloud_type = _normalize_text(custom_cloud_type)
|
||||
if (cloud_type_id is None) == (custom_cloud_type is None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="必须选择一个云类型,或填写自定义云类型",
|
||||
)
|
||||
if (latitude is None) != (longitude is None):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="经纬度必须同时填写")
|
||||
|
||||
cloud_type = await _require_cloud_type(db, cloud_type_id) if cloud_type_id else None
|
||||
image_path, thumbnail_path = await save_cloud_image(image, user.id)
|
||||
cloud = Cloud(
|
||||
user_id=user.id,
|
||||
cloud_type_id=cloud_type_id,
|
||||
custom_cloud_type=custom_cloud_type,
|
||||
image_path=image_path,
|
||||
thumbnail_path=thumbnail_path,
|
||||
latitude=_blur_coordinate(latitude),
|
||||
longitude=_blur_coordinate(longitude),
|
||||
location_name=_normalize_text(location_name),
|
||||
description=_normalize_text(description),
|
||||
captured_at=_normalize_datetime(captured_at) or datetime.now(timezone.utc),
|
||||
status="pending",
|
||||
is_hidden=is_hidden,
|
||||
)
|
||||
db.add(cloud)
|
||||
unlocked_badge: BadgeOut | None = None
|
||||
try:
|
||||
await db.flush()
|
||||
if cloud_type:
|
||||
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
insert_result = await db.execute(
|
||||
insert_factory(UserCollection)
|
||||
.values(
|
||||
user_id=user.id,
|
||||
cloud_type_id=cloud_type.id,
|
||||
first_cloud_id=cloud.id,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[UserCollection.user_id, UserCollection.cloud_type_id]
|
||||
)
|
||||
.returning(UserCollection.unlocked_at)
|
||||
)
|
||||
unlocked_at = insert_result.scalar_one_or_none()
|
||||
if unlocked_at:
|
||||
unlocked_badge = BadgeOut(
|
||||
cloud_type_id=cloud_type.id,
|
||||
cloud_name=cloud_type.name,
|
||||
cloud_name_en=cloud_type.name_en,
|
||||
rarity=cloud_type.rarity,
|
||||
unlocked_at=unlocked_at,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
await delete_files([image_path, thumbnail_path])
|
||||
raise
|
||||
|
||||
saved = await _get_cloud(db, cloud.id)
|
||||
if not saved:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="图片记录创建失败")
|
||||
return CloudCreateOut(cloud=cloud_out(saved), unlocked_badge=unlocked_badge)
|
||||
|
||||
|
||||
@router.patch("/{cloud_id}", response_model=CloudOut)
|
||||
async def update_cloud(
|
||||
cloud_id: uuid.UUID,
|
||||
payload: CloudUpdateIn,
|
||||
db: DbSession,
|
||||
user: CurrentUser,
|
||||
) -> CloudOut:
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
|
||||
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
|
||||
final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type))
|
||||
if "cloud_type_id" in changes and changes["cloud_type_id"] is not None and "custom_cloud_type" not in changes:
|
||||
final_custom = None
|
||||
if "custom_cloud_type" in changes and final_custom is not None and "cloud_type_id" not in changes:
|
||||
final_type_id = None
|
||||
if (final_type_id is None) == (final_custom is None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="必须选择一个云类型,或填写自定义云类型",
|
||||
)
|
||||
if final_type_id is not None:
|
||||
await _require_cloud_type(db, final_type_id)
|
||||
|
||||
if "cloud_type_id" in changes or "custom_cloud_type" in changes:
|
||||
cloud.cloud_type_id = final_type_id
|
||||
cloud.custom_cloud_type = final_custom
|
||||
if "latitude" in changes:
|
||||
cloud.latitude = _blur_coordinate(changes["latitude"])
|
||||
cloud.longitude = _blur_coordinate(changes["longitude"])
|
||||
if "location_name" in changes:
|
||||
cloud.location_name = _normalize_text(changes["location_name"])
|
||||
if "description" in changes:
|
||||
cloud.description = _normalize_text(changes["description"])
|
||||
if "captured_at" in changes:
|
||||
cloud.captured_at = _normalize_datetime(changes["captured_at"])
|
||||
if "is_hidden" in changes:
|
||||
cloud.is_hidden = changes["is_hidden"]
|
||||
await db.commit()
|
||||
|
||||
updated = await _get_cloud(db, cloud.id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
return cloud_out(updated)
|
||||
|
||||
|
||||
@router.delete("/{cloud_id}", response_model=DeleteResultOut)
|
||||
async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> DeleteResultOut:
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
|
||||
paths = [cloud.image_path, cloud.thumbnail_path]
|
||||
await db.delete(cloud)
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=1)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=DeleteResultOut)
|
||||
async def batch_delete_clouds(
|
||||
payload: BatchIdsIn,
|
||||
db: DbSession,
|
||||
user: CurrentUser,
|
||||
) -> DeleteResultOut:
|
||||
result = await db.execute(
|
||||
select(Cloud).where(Cloud.id.in_(payload.ids), Cloud.user_id == user.id)
|
||||
)
|
||||
clouds = result.scalars().all()
|
||||
if len(clouds) != len(payload.ids):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户")
|
||||
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||
for cloud in clouds:
|
||||
await db.delete(cloud)
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=len(clouds))
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import CurrentUser, DbSession
|
||||
from app.models import UserCollection
|
||||
from app.schemas import CollectionCloudOut, CollectionOut
|
||||
from app.serializers import cloud_type_summary, media_url
|
||||
|
||||
|
||||
router = APIRouter(prefix="/collections", tags=["图鉴"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=list[CollectionOut])
|
||||
async def get_my_collection(user: CurrentUser, db: DbSession) -> list[CollectionOut]:
|
||||
result = await db.execute(
|
||||
select(UserCollection)
|
||||
.options(joinedload(UserCollection.cloud_type), joinedload(UserCollection.first_cloud))
|
||||
.where(UserCollection.user_id == user.id)
|
||||
.order_by(UserCollection.unlocked_at.asc())
|
||||
)
|
||||
output: list[CollectionOut] = []
|
||||
for item in result.scalars().unique().all():
|
||||
first_cloud = item.first_cloud
|
||||
output.append(
|
||||
CollectionOut(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
cloud_type_id=item.cloud_type_id,
|
||||
first_cloud_id=item.first_cloud_id,
|
||||
unlocked_at=item.unlocked_at,
|
||||
cloud_type=cloud_type_summary(item.cloud_type),
|
||||
first_cloud=(
|
||||
CollectionCloudOut(
|
||||
id=first_cloud.id,
|
||||
image_url=media_url(first_cloud.image_path) or "",
|
||||
thumbnail_url=media_url(first_cloud.thumbnail_path) or "",
|
||||
captured_at=first_cloud.captured_at,
|
||||
created_at=first_cloud.created_at,
|
||||
location_name=first_cloud.location_name,
|
||||
)
|
||||
if first_cloud
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return output
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.deps import DbSession
|
||||
|
||||
|
||||
router = APIRouter(tags=["系统"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health(db: DbSession) -> dict[str, str]:
|
||||
try:
|
||||
await db.execute(text("select 1"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="数据库不可用",
|
||||
) from exc
|
||||
return {"status": "ok", "database": "ok"}
|
||||
@@ -0,0 +1,73 @@
|
||||
import math
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||
from app.models import Cloud, Profile, User
|
||||
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileUpdateIn, PublicProfileOut
|
||||
from app.serializers import cloud_out, profile_out, public_profile_out
|
||||
|
||||
|
||||
router = APIRouter(prefix="/profiles", tags=["用户资料"])
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=PublicProfileOut)
|
||||
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut:
|
||||
profile = await db.get(Profile, user_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return public_profile_out(profile)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=ProfileOut)
|
||||
async def update_my_profile(
|
||||
payload: ProfileUpdateIn,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ProfileOut:
|
||||
user.profile.username = payload.username
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||
return profile_out(user.profile)
|
||||
|
||||
|
||||
@router.get("/{user_id}/clouds", response_model=PageOut[CloudOut])
|
||||
async def get_profile_clouds(
|
||||
user_id: uuid.UUID,
|
||||
db: DbSession,
|
||||
viewer: OptionalUser,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
) -> PageOut[CloudOut]:
|
||||
if not await db.get(Profile, user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
can_manage = bool(viewer and (viewer.id == user_id or viewer.profile.role == "admin"))
|
||||
filters = [Cloud.user_id == user_id]
|
||||
if not can_manage:
|
||||
filters.extend([Cloud.status == "approved", Cloud.is_hidden.is_(False)])
|
||||
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [cloud_out(item, include_private=can_manage) for item in result.scalars().unique().all()]
|
||||
return PageOut(
|
||||
items=items,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=max(1, math.ceil(total / page_size)),
|
||||
)
|
||||
Reference in New Issue
Block a user