airlabs-manage/backend/routers/submissions.py
seaislee1209 f9016ab2af
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 3m4s
Build and Deploy Web / build-and-deploy (push) Successful in 17m17s
feat: 项目详情显示修补镜头 + 产出时长必填验证
- 项目详情中期模块显示修补镜头(后期秒数,归入制作产出计算)
- 产出时长改为必填项(非前期内容必须 > 0)
- 前端+后端双重验证,防止提交0秒产出
- 删除"无产出秒数的工作可填 0"过时提示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-02 22:41:20 +08:00

325 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""内容提交路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import date
from database import get_db
from models import (
User, Submission, SubmissionHistory, Project, ProjectMilestone,
PhaseGroup, WorkType, ContentType, SubmitTo, CONTENT_PHASE_MAP
)
from datetime import timedelta
from schemas import SubmissionCreate, SubmissionUpdate, SubmissionOut
from auth import get_current_user, require_permission
router = APIRouter(prefix="/api/submissions", tags=["内容提交"])
def submission_to_out(s: Submission) -> SubmissionOut:
return SubmissionOut(
id=s.id, user_id=s.user_id,
user_name=s.user.name if s.user else None,
project_id=s.project_id,
project_name=s.project.name if s.project else None,
project_phase=s.project_phase.value if hasattr(s.project_phase, 'value') else s.project_phase,
work_type=s.work_type.value if hasattr(s.work_type, 'value') else s.work_type,
content_type=s.content_type.value if hasattr(s.content_type, 'value') else s.content_type,
duration_minutes=s.duration_minutes,
duration_seconds=s.duration_seconds,
total_seconds=s.total_seconds,
hours_spent=s.hours_spent,
submit_to=s.submit_to.value if hasattr(s.submit_to, 'value') else s.submit_to,
description=s.description,
submit_date=s.submit_date,
milestone_name=s.milestone.name if s.milestone else None,
delay_reason=s.delay_reason,
episode_number=s.episode_number,
created_at=s.created_at,
)
@router.get("", response_model=List[SubmissionOut])
def list_submissions(
project_id: Optional[int] = Query(None),
user_id: Optional[int] = Query(None),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
q = db.query(Submission)
# 查看项目内提交时,所有人都可见(方便横向对比)
# 全局提交列表时,没有 user:view 权限只能看自己的
if project_id:
pass # 项目内提交不做用户过滤
elif not current_user.has_permission("user:view"):
q = q.filter(Submission.user_id == current_user.id)
elif user_id:
q = q.filter(Submission.user_id == user_id)
if project_id:
q = q.filter(Submission.project_id == project_id)
if start_date:
q = q.filter(Submission.submit_date >= start_date)
if end_date:
q = q.filter(Submission.submit_date <= end_date)
subs = q.order_by(Submission.submit_date.desc(), Submission.created_at.desc()).all()
return [submission_to_out(s) for s in subs]
@router.post("")
def create_submission(
req: SubmissionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# 代人提交:有 submission:proxy 权限时可以指定 user_id
target_user_id = current_user.id
if req.user_id and req.user_id != current_user.id:
if not current_user.has_permission("submission:proxy"):
raise HTTPException(status_code=403, detail="没有代人提交权限")
target_user = db.query(User).filter(User.id == req.user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="目标用户不存在")
target_user_id = req.user_id
# 必填校验
if not req.description or not req.description.strip():
raise HTTPException(status_code=422, detail="请填写描述")
if req.hours_spent is None or req.hours_spent <= 0:
raise HTTPException(status_code=422, detail="请填写投入时长")
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
if content_val not in PRE_PHASE_TYPES:
total_secs = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
if total_secs <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长")
# 确定集数列表
from models import PROJECT_LEVEL_TYPES
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
if content_val in PROJECT_LEVEL_TYPES:
episode_list = [None] # 项目级,单条无集数
elif req.episode_numbers and len(req.episode_numbers) > 0:
episode_list = req.episode_numbers # 批量多集
elif req.episode_number is not None:
episode_list = [req.episode_number] # 单集含0=全集通用)
else:
raise HTTPException(status_code=422, detail="请选择集数")
# 校验项目存在
project = db.query(Project).filter(Project.id == req.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 自动计算总秒数(每集)
total_seconds = (req.duration_minutes or 0) * 60 + (req.duration_seconds or 0)
# 自动关联里程碑
milestone_id = None
content_val = req.content_type
if content_val in CONTENT_PHASE_MAP:
ms = db.query(ProjectMilestone).filter(
ProjectMilestone.project_id == req.project_id,
ProjectMilestone.name == content_val,
).first()
if ms:
milestone_id = ms.id
if ms.estimated_days and ms.start_date:
expected_end = ms.start_date + timedelta(days=ms.estimated_days)
if req.submit_date > expected_end and not req.delay_reason:
raise HTTPException(
status_code=422,
detail=f"里程碑「{ms.name}」已超期(预估{ms.estimated_days}天),请填写延期原因"
)
# 投入时长平均分配到每集
ep_count = len(episode_list)
hours_per_ep = round(req.hours_spent / ep_count, 2)
results = []
for ep in episode_list:
sub = Submission(
user_id=target_user_id,
project_id=req.project_id,
project_phase=PhaseGroup(req.project_phase),
work_type=WorkType(req.work_type),
content_type=ContentType(req.content_type),
duration_minutes=req.duration_minutes or 0,
duration_seconds=req.duration_seconds or 0,
total_seconds=total_seconds,
hours_spent=hours_per_ep,
submit_to=SubmitTo(req.submit_to),
description=req.description,
submit_date=req.submit_date,
milestone_id=milestone_id,
delay_reason=req.delay_reason,
episode_number=ep,
)
db.add(sub)
results.append(sub)
db.commit()
for s in results:
db.refresh(s)
if len(results) == 1:
return submission_to_out(results[0])
return [submission_to_out(s) for s in results]
@router.put("/{submission_id}", response_model=SubmissionOut)
def update_submission(
submission_id: int,
req: SubmissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("submission:create"))
):
"""高权限修改提交记录(需填写原因)"""
sub = db.query(Submission).filter(Submission.id == submission_id).first()
if not sub:
raise HTTPException(status_code=404, detail="提交记录不存在")
# 保存旧数据用于历史记录
old_data = {
"project_phase": sub.project_phase.value if hasattr(sub.project_phase, 'value') else sub.project_phase,
"work_type": sub.work_type.value if hasattr(sub.work_type, 'value') else sub.work_type,
"content_type": sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type,
"duration_minutes": sub.duration_minutes,
"duration_seconds": sub.duration_seconds,
"total_seconds": sub.total_seconds,
"hours_spent": sub.hours_spent,
"submit_to": sub.submit_to.value if hasattr(sub.submit_to, 'value') else sub.submit_to,
"description": sub.description,
"submit_date": str(sub.submit_date),
}
# 更新字段
if req.project_phase is not None:
sub.project_phase = PhaseGroup(req.project_phase)
if req.work_type is not None:
sub.work_type = WorkType(req.work_type)
if req.content_type is not None:
sub.content_type = ContentType(req.content_type)
if req.duration_minutes is not None:
sub.duration_minutes = req.duration_minutes
if req.duration_seconds is not None:
sub.duration_seconds = req.duration_seconds
if req.hours_spent is not None:
sub.hours_spent = req.hours_spent
if req.submit_to is not None:
sub.submit_to = SubmitTo(req.submit_to)
if req.description is not None:
sub.description = req.description
if req.submit_date is not None:
sub.submit_date = req.submit_date
if req.episode_number is not None:
sub.episode_number = req.episode_number
# 重算总秒数
sub.total_seconds = (sub.duration_minutes or 0) * 60 + (sub.duration_seconds or 0)
# 产出时长校验:前期内容不需要,中期/后期内容必须 > 0
PRE_PHASE_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图'}
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
if content_val not in PRE_PHASE_TYPES and sub.total_seconds <= 0:
raise HTTPException(status_code=422, detail="请填写产出时长")
# 保存新数据
new_data = {
"project_phase": sub.project_phase.value if hasattr(sub.project_phase, 'value') else sub.project_phase,
"work_type": sub.work_type.value if hasattr(sub.work_type, 'value') else sub.work_type,
"content_type": sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type,
"duration_minutes": sub.duration_minutes,
"duration_seconds": sub.duration_seconds,
"total_seconds": sub.total_seconds,
"hours_spent": sub.hours_spent,
"submit_to": sub.submit_to.value if hasattr(sub.submit_to, 'value') else sub.submit_to,
"description": sub.description,
"submit_date": str(sub.submit_date),
}
# 写入修改历史
history = SubmissionHistory(
submission_id=sub.id,
changed_by=current_user.id,
change_reason=req.change_reason,
old_data=old_data,
new_data=new_data,
)
db.add(history)
db.commit()
db.refresh(sub)
return submission_to_out(sub)
@router.get("/{submission_id}/history")
def get_submission_history(
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("submission:view"))
):
"""查看提交的修改历史"""
records = db.query(SubmissionHistory).filter(
SubmissionHistory.submission_id == submission_id
).order_by(SubmissionHistory.created_at.desc()).all()
return [
{
"id": r.id,
"changed_by": r.changed_by,
"change_reason": r.change_reason,
"old_data": r.old_data,
"new_data": r.new_data,
"created_at": r.created_at,
}
for r in records
]
@router.delete("/{submission_id}")
def delete_submission(
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("submission:delete"))
):
"""删除提交记录(需要 submission:delete 权限)"""
sub = db.query(Submission).filter(Submission.id == submission_id).first()
if not sub:
raise HTTPException(status_code=404, detail="提交记录不存在")
# 同时删除关联的修改历史
db.query(SubmissionHistory).filter(
SubmissionHistory.submission_id == submission_id
).delete()
db.delete(sub)
db.commit()
return {"detail": "删除成功"}
@router.get("/daily-hours")
def get_daily_hours(
user_id: Optional[int] = Query(None),
target_date: Optional[date] = Query(None, alias="date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""查询指定用户某日已填工时"""
from sqlalchemy import func
uid = user_id or current_user.id
# 非本人查询需要 proxy 权限
if uid != current_user.id and not current_user.has_permission("submission:proxy"):
raise HTTPException(status_code=403, detail="无权查看他人工时")
d = target_date or date.today()
filled = db.query(func.sum(Submission.hours_spent)).filter(
Submission.user_id == uid,
Submission.submit_date == d,
).scalar() or 0.0
target = 8.0
return {
"filled": round(filled, 1),
"target": target,
"remaining": round(max(target - filled, 0), 1),
}