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"}