From 86911286c08cc053ee5e52fa5520f16cf7fac481 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 19:18:20 +0000 Subject: [PATCH] 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 --- app/api/routers/meetings.py | 289 ++++++++++++++++++++++++++++++++++++ app/main.py | 2 + app/models/meeting.py | 18 ++- 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 app/api/routers/meetings.py diff --git a/app/api/routers/meetings.py b/app/api/routers/meetings.py new file mode 100644 index 0000000..27b13bd --- /dev/null +++ b/app/api/routers/meetings.py @@ -0,0 +1,289 @@ +"""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) diff --git a/app/main.py b/app/main.py index 524f46c..2d6b19a 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router from app.api.routers.proposes import router as proposes_router from app.api.routers.milestone_actions import router as milestone_actions_router +from app.api.routers.meetings import router as meetings_router app.include_router(auth_router) app.include_router(tasks_router) @@ -52,6 +53,7 @@ app.include_router(milestones_router) app.include_router(roles_router) app.include_router(proposes_router) app.include_router(milestone_actions_router) +app.include_router(meetings_router) # Auto schema migration for lightweight deployments diff --git a/app/models/meeting.py b/app/models/meeting.py index 739476d..e94f49d 100644 --- a/app/models/meeting.py +++ b/app/models/meeting.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.config import Base @@ -35,3 +35,19 @@ class Meeting(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan") + + +class MeetingParticipant(Base): + __tablename__ = "meeting_participants" + __table_args__ = ( + UniqueConstraint("meeting_id", "user_id", name="uq_meeting_participant"), + ) + + id = Column(Integer, primary_key=True, index=True) + meeting_id = Column(Integer, ForeignKey("meetings.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + joined_at = Column(DateTime(timezone=True), server_default=func.now()) + + meeting = relationship("Meeting", back_populates="participants")