Initial project commit
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user