Files
CosScene/server/app/api/v1/endpoints/shooting.py
T
2026-05-09 16:40:29 +08:00

403 lines
15 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db, get_optional_current_user
from app.models.shooting import ShootingApplication, ShootingRequest
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.shooting import (
ApplicationCreate,
ApplicationOut,
ShootingRequestBrief,
ShootingRequestCreate,
ShootingRequestDetail,
ShootingRequestUpdate,
)
from app.services.notification_service import send_notification
router = APIRouter()
ROLE_LABELS = {
"photographer": "摄影师",
"cosplayer": "Coser",
"both": "不限",
}
def _to_brief(sr: ShootingRequest) -> ShootingRequestBrief:
return ShootingRequestBrief(
id=sr.id,
title=sr.title,
city=sr.city,
style=sr.style,
shoot_date=sr.shoot_date,
is_free=sr.is_free,
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
role_needed=sr.role_needed,
status=sr.status,
audit_status=sr.audit_status,
creator=sr.creator,
application_count=len(sr.applications) if sr.applications else 0,
created_at=sr.created_at,
)
# --- ShootingRequest CRUD ---
@router.post("/", response_model=ShootingRequestBrief, status_code=status.HTTP_201_CREATED)
async def create_shooting_request(
payload: ShootingRequestCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr = ShootingRequest(
creator_id=current_user.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="open",
audit_status="pending",
)
db.add(sr)
await db.commit()
await db.refresh(sr)
return _to_brief(sr)
@router.get("/", response_model=PageResponse[ShootingRequestBrief])
async def list_shooting_requests(
city: str | None = None,
style: str | None = None,
role_needed: str | None = None,
is_free: bool | None = None,
status_filter: str | None = Query(None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
base = select(ShootingRequest).where(ShootingRequest.audit_status == "approved")
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.audit_status == "approved")
if city:
base = base.where(ShootingRequest.city.ilike(f"%{city}%"))
count_base = count_base.where(ShootingRequest.city.ilike(f"%{city}%"))
if style:
base = base.where(ShootingRequest.style.ilike(f"%{style}%"))
count_base = count_base.where(ShootingRequest.style.ilike(f"%{style}%"))
if role_needed:
base = base.where(ShootingRequest.role_needed == role_needed)
count_base = count_base.where(ShootingRequest.role_needed == role_needed)
if is_free is not None:
base = base.where(ShootingRequest.is_free == is_free)
count_base = count_base.where(ShootingRequest.is_free == is_free)
if status_filter:
base = base.where(ShootingRequest.status == status_filter)
count_base = count_base.where(ShootingRequest.status == status_filter)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
@router.get("/mine", response_model=PageResponse[ShootingRequestBrief])
async def list_my_shooting_requests(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
base = select(ShootingRequest).where(ShootingRequest.creator_id == current_user.id)
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.creator_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
@router.get("/my-applications", response_model=PageResponse[ApplicationOut])
async def list_my_applications(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
base = select(ShootingApplication).where(ShootingApplication.applicant_id == current_user.id)
count_base = select(func.count(ShootingApplication.id)).where(ShootingApplication.applicant_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingApplication.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=items)
@router.get("/{request_id}", response_model=ShootingRequestDetail)
async def get_shooting_request(
request_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
has_applied = False
if current_user:
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
has_applied = app_result.scalar_one_or_none() is not None
return ShootingRequestDetail(
id=sr.id,
title=sr.title,
city=sr.city,
style=sr.style,
shoot_date=sr.shoot_date,
is_free=sr.is_free,
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
role_needed=sr.role_needed,
status=sr.status,
audit_status=sr.audit_status,
creator=sr.creator,
application_count=len(sr.applications) if sr.applications else 0,
created_at=sr.created_at,
description=sr.description,
max_applicants=sr.max_applicants,
contact_info=sr.contact_info if (current_user and (sr.creator_id == current_user.id or current_user.role in ("admin", "moderator"))) else None,
spot_id=sr.spot_id,
reject_reason=sr.reject_reason,
has_applied=has_applied,
)
@router.put("/{request_id}", response_model=ShootingRequestBrief)
async def update_shooting_request(
request_id: int,
payload: ShootingRequestUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(sr, field, value)
await db.commit()
await db.refresh(sr)
return _to_brief(sr)
@router.post("/{request_id}/close")
async def close_shooting_request(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
sr.status = "closed"
await db.commit()
return {"code": 0, "message": "closed"}
# --- Applications ---
@router.post("/{request_id}/apply", response_model=ApplicationOut, status_code=status.HTTP_201_CREATED)
async def apply_to_shooting(
request_id: int,
payload: ApplicationCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.status != "open":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍已关闭")
if sr.audit_status != "approved":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍尚未通过审核")
if sr.creator_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能报名自己的约拍")
existing = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="已经报名过了")
app = ShootingApplication(
request_id=request_id,
applicant_id=current_user.id,
message=payload.message,
status="pending",
)
db.add(app)
await send_notification(
db, sr.creator_id, "shooting",
f"有人报名了您的约拍「{sr.title}",
content=f"{current_user.nickname} 报名了您的约拍",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
await db.refresh(app)
return app
@router.get("/{request_id}/applications", response_model=list[ApplicationOut])
async def list_applications(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can view applications")
result = await db.execute(
select(ShootingApplication)
.where(ShootingApplication.request_id == request_id)
.order_by(ShootingApplication.created_at.desc())
)
return result.scalars().all()
@router.post("/{request_id}/applications/{app_id}/accept")
async def accept_application(
request_id: int,
app_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = sr_result.scalar_one_or_none()
if not sr or sr.creator_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.id == app_id,
ShootingApplication.request_id == request_id,
)
)
app = app_result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
if app.status != "pending":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报名已被处理")
app.status = "accepted"
accepted_count = sum(1 for a in sr.applications if a.status == "accepted") + 1
if accepted_count >= sr.max_applicants:
sr.status = "matched"
await send_notification(
db, app.applicant_id, "shooting",
f"您的约拍报名「{sr.title}」已被接受",
content="对方已接受您的报名,请查看详情。",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
return {"code": 0, "message": "accepted"}
@router.post("/{request_id}/applications/{app_id}/reject")
async def reject_application(
request_id: int,
app_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = sr_result.scalar_one_or_none()
if not sr or sr.creator_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.id == app_id,
ShootingApplication.request_id == request_id,
)
)
app = app_result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
app.status = "rejected"
await send_notification(
db, app.applicant_id, "shooting",
f"您的约拍报名「{sr.title}」未通过",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
return {"code": 0, "message": "rejected"}
@router.delete("/{request_id}/applications/withdraw")
async def withdraw_application(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
if app.status == "accepted":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已被接受的报名无法撤回")
await db.delete(app)
await db.commit()
return {"code": 0, "message": "withdrawn"}