Files
HarborForge.Backend/app/api/routers/meetings.py
zhi 86911286c0 feat: add code-based meetings router with participant/attend support
- New dedicated meetings.py router with full CRUD (list/get/create/update/delete)
- All endpoints accept meeting_code or numeric id
- MeetingParticipant model for tracking meeting attendance
- POST /meetings/{id}/attend adds current user to participant list
- Serialization includes participants list, project_code, milestone_code
- Creator auto-added as participant on meeting creation
- Registered in main.py alongside existing routers
2026-03-21 19:18:20 +00:00

290 lines
10 KiB
Python

"""Meetings router — code-first CRUD with participant/attend support."""
import math
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.models.meeting import Meeting, MeetingStatus, MeetingPriority, MeetingParticipant
from app.models.milestone import Milestone
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
router = APIRouter(tags=["Meetings"])
# ---- helpers ----
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None:
try:
mid = int(identifier)
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
if meeting:
return meeting
except (ValueError, TypeError):
pass
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first()
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
if not project_code:
return None
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")
return project.id
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if not milestone_code:
return None
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
ms = query.first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
def _get_participant_usernames(db: Session, meeting: Meeting) -> list[str]:
parts = db.query(MeetingParticipant).filter(MeetingParticipant.meeting_id == meeting.id).all()
usernames = []
for p in parts:
user = db.query(models.User).filter(models.User.id == p.user_id).first()
if user:
usernames.append(user.username)
return usernames
def _serialize_meeting(db: Session, meeting: Meeting) -> dict:
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
return {
"id": meeting.id,
"code": meeting.meeting_code,
"meeting_code": meeting.meeting_code,
"title": meeting.title,
"description": meeting.description,
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
"project_id": meeting.project_id,
"project_code": project.project_code if project else None,
"milestone_id": meeting.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": meeting.reporter_id,
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
"scheduled_at": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
"duration_minutes": meeting.duration_minutes,
"participants": _get_participant_usernames(db, meeting),
"created_at": meeting.created_at.isoformat() if meeting.created_at else None,
"updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None,
}
# ---- CRUD ----
class MeetingCreateBody(BaseModel):
project_code: str
title: str
milestone_code: Optional[str] = None
description: Optional[str] = None
meeting_time: Optional[str] = None
duration_minutes: Optional[int] = None
@router.post("/meetings", status_code=status.HTTP_201_CREATED)
def create_meeting(
body: MeetingCreateBody,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
project_id = _resolve_project_id(db, body.project_code)
if not project_id:
raise HTTPException(status_code=400, detail="project_code is required")
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = _resolve_milestone(db, body.milestone_code, project_id)
if not milestone:
# If no milestone_code, try to use the first active milestone
milestone = db.query(Milestone).filter(
Milestone.project_id == project_id,
).order_by(Milestone.id.desc()).first()
if not milestone:
raise HTTPException(status_code=400, detail="No milestone available for project")
milestone_code = milestone.milestone_code or f"m{milestone.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 body.meeting_time:
try:
scheduled_at = datetime.fromisoformat(body.meeting_time.replace("Z", "+00:00"))
except Exception:
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
meeting = Meeting(
title=body.title,
description=body.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=body.duration_minutes,
)
db.add(meeting)
db.commit()
db.refresh(meeting)
# Auto-add creator as participant
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
db.add(participant)
db.commit()
return _serialize_meeting(db, meeting)
@router.get("/meetings")
def list_meetings(
project: str = None,
status_value: str = Query(None, alias="status"),
order_by: str = None,
page: int = 1,
page_size: int = 50,
db: Session = Depends(get_db),
):
query = db.query(Meeting)
if project:
project_id = _resolve_project_id(db, project)
if project_id:
query = query.filter(Meeting.project_id == project_id)
if status_value:
query = query.filter(Meeting.status == status_value)
sort_fields = {
"created": Meeting.created_at,
"created_at": Meeting.created_at,
"due-date": Meeting.scheduled_at,
"scheduled_at": Meeting.scheduled_at,
"name": Meeting.title,
"title": Meeting.title,
}
sort_col = sort_fields.get(order_by, Meeting.created_at)
query = query.order_by(sort_col.desc())
total = query.count()
page = max(1, page)
page_size = min(max(1, page_size), 200)
total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [_serialize_meeting(db, m) for m in items],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/meetings/{meeting_id}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
return _serialize_meeting(db, meeting)
class MeetingUpdateBody(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
meeting_time: Optional[str] = None
duration_minutes: Optional[int] = None
@router.patch("/meetings/{meeting_id}")
def update_meeting(
meeting_id: str,
body: MeetingUpdateBody,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No supported fields to update")
if "title" in update_data and update_data["title"] is not None:
meeting.title = update_data["title"]
if "description" in update_data:
meeting.description = update_data["description"]
if "status" in update_data and update_data["status"] is not None:
meeting.status = MeetingStatus(update_data["status"])
if "meeting_time" in update_data and update_data["meeting_time"] is not None:
try:
meeting.scheduled_at = datetime.fromisoformat(update_data["meeting_time"].replace("Z", "+00:00"))
except Exception:
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
if "duration_minutes" in update_data:
meeting.duration_minutes = update_data["duration_minutes"]
db.commit()
db.refresh(meeting)
return _serialize_meeting(db, meeting)
@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
db.delete(meeting)
db.commit()
return None
# ---- Attend ----
@router.post("/meetings/{meeting_id}/attend")
def attend_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")
existing = db.query(MeetingParticipant).filter(
MeetingParticipant.meeting_id == meeting.id,
MeetingParticipant.user_id == current_user.id,
).first()
if existing:
return _serialize_meeting(db, meeting)
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
db.add(participant)
db.commit()
return _serialize_meeting(db, meeting)