- 编辑弹窗中所属项目改为可选下拉 - 后端 SubmissionUpdate 新增 project_id 字段 - 切换到内部事务项目时自动调整阶段和内容类型 - 修复编辑时产出时长校验变量名 bug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""内容提交路由"""
|
||
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),
|
||
}
|