airlabs-manage/backend/routers/submissions.py
seaislee1209 4629525d2a
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 1m24s
Build and Deploy Web / build-and-deploy (push) Successful in 54s
feat: 编辑提交记录支持修改所属项目
- 编辑弹窗中所属项目改为可选下拉
- 后端 SubmissionUpdate 新增 project_id 字段
- 切换到内部事务项目时自动调整阶段和内容类型
- 修复编辑时产出时长校验变量名 bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:17:34 +08:00

329 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 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
NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
content_val = req.content_type.value if hasattr(req.content_type, 'value') else req.content_type
if content_val not in NO_DURATION_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_id is not None:
project = db.query(Project).filter(Project.id == req.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
sub.project_id = req.project_id
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
NO_DURATION_TYPES = {'策划案', '大纲/梗概', '概念设计图', '测试片', '剧本', '分镜', '人设图', '场景图', '道具图', '培训', '招聘面试', '内部其他'}
content_val = sub.content_type.value if hasattr(sub.content_type, 'value') else sub.content_type
if content_val not in NO_DURATION_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),
}