Files
HarborForge.Backend/app/api/routers/misc.py

803 lines
32 KiB
Python

"""Miscellaneous routers: API keys, activity, milestones, notifications, worklogs, export, dashboard."""
import csv
import io
import secrets
import math
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func as sqlfunc
from pydantic import BaseModel
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models import models
from app.models.apikey import APIKey
from app.models.activity import ActivityLog
from app.models.milestone import Milestone as MilestoneModel
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.support import Support, SupportStatus, SupportPriority
from app.models.meeting import Meeting, MeetingStatus, MeetingPriority
from app.models.notification import Notification as NotificationModel
from app.models.worklog import WorkLog
from app.schemas import schemas
router = APIRouter()
# ============ API Keys ============
class APIKeyCreate(BaseModel):
name: str
user_id: int
class APIKeyResponse(BaseModel):
id: int
key: str
name: str
user_id: int
is_active: bool
created_at: datetime
last_used_at: datetime | None = None
class Config:
from_attributes = True
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
key = secrets.token_hex(32)
db_key = APIKey(key=key, name=data.name, user_id=data.user_id)
db.add(db_key)
db.commit()
db.refresh(db_key)
return db_key
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
query = db.query(APIKey)
if user_id:
query = query.filter(APIKey.user_id == user_id)
return query.all()
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
if not key_obj:
raise HTTPException(status_code=404, detail="API key not found")
key_obj.is_active = False
db.commit()
return None
# ============ Activity Log ============
class ActivityLogResponse(BaseModel):
id: int
action: str
entity_type: str
entity_id: int
user_id: int | None
details: str | None
created_at: datetime
class Config:
from_attributes = True
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
limit: int = 50, db: Session = Depends(get_db)):
query = db.query(ActivityLog)
if entity_type:
query = query.filter(ActivityLog.entity_type == entity_type)
if entity_id:
query = query.filter(ActivityLog.entity_id == entity_id)
if user_id:
query = query.filter(ActivityLog.user_id == user_id)
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
# ============ Milestones ============
@router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
# Get max milestone number for this project
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}"
data = ms.model_dump()
# Serialize list fields to JSON strings
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
else:
data["depend_on_milestones"] = None
if data.get("depend_on_tasks"):
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
else:
data["depend_on_tasks"] = None
db_ms = MilestoneModel(**data)
db_ms.milestone_code = milestone_code
db.add(db_ms)
db.commit()
db.refresh(db_ms)
return db_ms
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)):
query = db.query(MilestoneModel)
if project_id:
query = query.filter(MilestoneModel.project_id == project_id)
if status_filter:
query = query.filter(MilestoneModel.status == status_filter)
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def get_milestone(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
for field, value in ms_update.model_dump(exclude_unset=True).items():
setattr(ms, field, value)
db.commit()
db.refresh(ms)
return ms
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
db.delete(ms)
db.commit()
return None
@router.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse], tags=["Milestones"])
def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)):
return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
from datetime import datetime
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only
issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == "task"
).all()
total = len(issues)
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
time_progress = None
if ms.planned_release_date and ms.created_at:
now = datetime.now()
total_duration = (ms.planned_release_date - ms.created_at).total_seconds()
elapsed = (now - ms.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {"milestone_id": milestone_id, "title": ms.title, "total": total,
"completed": done, "progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date}
@router.get("/milestones/{milestone_id}/items", tags=["Milestones"])
def milestone_items(milestone_id: int, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == "task":
tasks.append(issue_data)
elif issue.issue_type == "support":
supports.append(issue_data)
elif issue.issue_type == "meeting":
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
@router.post("/milestones/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
from datetime import datetime, time
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is progressing
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Get project_id from milestone
project_id = ms.project_id
# Generate task_code: i_{project_code}_{id:06x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
# Get max id for this project to generate unique code
max_issue = db.query(models.Issue).filter(models.Issue.project_id == project_id).order_by(models.Issue.id.desc()).first()
next_id = (max_issue.id + 1) if max_issue else 1
task_code = f"{milestone_code}:T{next_num:05x}"
# Parse estimated_working_time if provided
est_time = None
if issue_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(issue_data["estimated_working_time"], "%H:%M").time()
except:
pass
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="task",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
# Task-specific fields
task_code=task_code,
estimated_effort=issue_data.get("estimated_effort"),
estimated_working_time=est_time,
task_status="open",
created_by_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
# Return with task_code
return {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"task_code": issue.task_code,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
@router.post("/milestones/{milestone_id}/supports", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_support(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="support",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
@router.post("/milestones/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_meeting(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="meeting",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
# ============ Notifications ============
class NotificationResponse(BaseModel):
id: int
user_id: int
type: str
title: str
message: str | None = None
entity_type: str | None = None
entity_id: int | None = None
is_read: bool
created_at: datetime
class Config:
from_attributes = True
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
def list_notifications(unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
if unread_only:
query = query.filter(NotificationModel.is_read == False)
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
@router.get("/notifications/count", tags=["Notifications"])
def notification_count(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
count = db.query(NotificationModel).filter(
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
).count()
return {"user_id": current_user.id, "count": count, "unread": count}
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
def mark_read(notification_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
if not n:
raise HTTPException(status_code=404, detail="Notification not found")
if n.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
n.is_read = True
db.commit()
return {"status": "read"}
@router.post("/notifications/read-all", tags=["Notifications"])
def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
db.query(NotificationModel).filter(
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
).update({"is_read": True})
db.commit()
return {"status": "all_read", "user_id": current_user.id}
# ============ Work Logs ============
class WorkLogCreate(BaseModel):
issue_id: int
user_id: int
hours: float
description: str | None = None
logged_date: datetime
class WorkLogResponse(BaseModel):
id: int
issue_id: int
user_id: int
hours: float
description: str | None = None
logged_date: datetime
created_at: datetime
class Config:
from_attributes = True
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if wl.hours <= 0:
raise HTTPException(status_code=400, detail="Hours must be positive")
db_wl = WorkLog(**wl.model_dump())
db.add(db_wl)
db.commit()
db.refresh(db_wl)
return db_wl
@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all()
@router.get("/issues/{issue_id}/worklogs/summary", tags=["Time Tracking"])
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count()
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count}
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
if not wl:
raise HTTPException(status_code=404, detail="Work log not found")
db.delete(wl)
db.commit()
return None
# ============ Export ============
@router.get("/export/issues", tags=["Export"])
def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
issues = query.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
"reporter_id", "assignee_id", "milestone_id", "due_date",
"tags", "created_at", "updated_at"])
for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
i.tags, i.created_at, i.updated_at])
output.seek(0)
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=issues.csv"})
# ============ Dashboard ============
@router.get("/dashboard/stats", tags=["Dashboard"])
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
query = db.query(models.Issue)
if project_id:
query = query.filter(models.Issue.project_id == project_id)
total = query.count()
by_status = {s: query.filter(models.Issue.status == s).count()
for s in ["open", "in_progress", "resolved", "closed", "blocked"]}
by_type = {t: query.filter(models.Issue.issue_type == t).count()
for t in ["task", "story", "test", "resolution"]}
by_priority = {p: query.filter(models.Issue.priority == p).count()
for p in ["low", "medium", "high", "critical"]}
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority}
# ============ Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
tasks = db.query(Task).filter(
Task.project_id == project.id,
Task.milestone_id == milestone_id
).all()
return [{
"id": t.id,
"title": t.title,
"description": t.description,
"status": t.status.value if hasattr(t.status, "value") else t.status,
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
"task_code": t.task_code,
"estimated_effort": t.estimated_effort,
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
"started_on": t.started_on,
"finished_on": t.finished_on,
"depend_on": t.depend_on,
"related_tasks": t.related_tasks,
"assignee_id": t.assignee_id,
"created_at": t.created_at,
} for t in tasks]
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate task_code: milestoneCode:T{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_task = db.query(Task).filter(Task.milestone_id == ms.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
task_code = f"{milestone_code}:T{next_num:05x}"
est_time = None
if task_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(task_data["estimated_working_time"], "%H:%M").time()
except:
pass
task = Task(
title=task_data.get("title"),
description=task_data.get("description"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=task_data.get("estimated_effort"),
estimated_working_time=est_time,
created_by_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(task)
return {
"id": task.id,
"title": task.title,
"description": task.description,
"task_code": task.task_code,
"status": task.status.value,
"priority": task.priority.value,
"created_at": task.created_at,
}
@router.get("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def get_task(project_code: str, milestone_id: int, task_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"id": task.id,
"title": task.title,
"description": task.description,
"status": task.status.value,
"priority": task.priority.value,
"task_code": task.task_code,
"estimated_effort": task.estimated_effort,
"estimated_working_time": str(task.estimated_working_time) if task.estimated_working_time else None,
"started_on": task.started_on,
"finished_on": task.finished_on,
"depend_on": task.depend_on,
"related_tasks": task.related_tasks,
"assignee_id": task.assignee_id,
"created_at": task.created_at,
}
@router.patch("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def update_task(project_code: str, milestone_id: int, task_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if "title" in task_data:
task.title = task_data["title"]
if "description" in task_data:
task.description = task_data["description"]
if "status" in task_data:
new_status = task_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.now()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.now()
task.status = TaskStatus[new_status.upper()] if new_status.upper() in [s.name for s in TaskStatus] else TaskStatus.OPEN
if "priority" in task_data:
task.priority = TaskPriority[task_data["priority"].upper()] if task_data["priority"].upper() in [s.name for s in TaskPriority] else TaskPriority.MEDIUM
if "estimated_effort" in task_data:
task.estimated_effort = task_data["estimated_effort"]
if "assignee_id" in task_data:
task.assignee_id = task_data["assignee_id"]
db.commit()
db.refresh(task)
return task
# ============ Supports ============
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
supports = db.query(Support).filter(
Support.project_id == project.id,
Support.milestone_id == milestone_id
).all()
return [{
"id": s.id,
"title": s.title,
"description": s.description,
"status": s.status.value,
"priority": s.priority.value,
"assignee_id": s.assignee_id,
"created_at": s.created_at,
} for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate support_code: milestoneCode:S{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1
support_code = f"{milestone_code}:S{next_num:05x}"
support = Support(
title=support_data.get("title"),
description=support_data.get("description"),
status=SupportStatus.OPEN,
priority=SupportPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
support_code=support_code,
)
db.add(support)
db.commit()
db.refresh(support)
return support
# ============ Meetings ============
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
meetings = db.query(Meeting).filter(
Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id
).all()
return [{
"id": m.id,
"title": m.title,
"description": m.description,
"status": m.status.value,
"priority": m.priority.value,
"scheduled_at": m.scheduled_at,
"duration_minutes": m.duration_minutes,
"created_at": m.created_at,
} for m in meetings]
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate meeting_code: milestoneCode:M{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1
meeting_code = f"{milestone_code}:M{next_num:05x}"
scheduled_at = None
if meeting_data.get("scheduled_at"):
try:
scheduled_at = datetime.fromisoformat(meeting_data["scheduled_at"].replace("Z", "+00:00"))
except:
pass
meeting = Meeting(
title=meeting_data.get("title"),
description=meeting_data.get("description"),
status=MeetingStatus.SCHEDULED,
priority=MeetingPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
meeting_code=meeting_code,
scheduled_at=scheduled_at,
duration_minutes=meeting_data.get("duration_minutes"),
)
db.add(meeting)
db.commit()
db.refresh(meeting)
return meeting