diff --git a/app/main.py b/app/main.py index 32ec79a..9a9a88a 100644 --- a/app/main.py +++ b/app/main.py @@ -132,6 +132,19 @@ def list_issues( return issues +@app.get("/issues/overdue", response_model=List[schemas.IssueResponse]) +def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)): + """List issues past their due date that aren't closed/resolved.""" + query = db.query(models.Issue).filter( + models.Issue.due_date != None, + models.Issue.due_date < datetime.utcnow(), + models.Issue.status.notin_(["resolved", "closed"]) + ) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + return query.order_by(models.Issue.due_date.asc()).all() + + @app.get("/issues/{issue_id}", response_model=schemas.IssueResponse) def get_issue(issue_id: int, db: Session = Depends(get_db)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() @@ -254,6 +267,7 @@ def startup(): from app.models import webhook from app.models import apikey from app.models import activity + from app.models import milestone Base.metadata.create_all(bind=engine) @@ -772,3 +786,117 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): if t: all_tags.add(t) return {"tags": sorted(all_tags)} + + +# ============ Milestones API ============ + +from app.models.milestone import Milestone as MilestoneModel, MilestoneStatus +from app.schemas.schemas import MilestoneCreate, MilestoneUpdate, MilestoneResponse + + +@app.post("/milestones", response_model=MilestoneResponse, status_code=status.HTTP_201_CREATED) +def create_milestone(ms: MilestoneCreate, db: Session = Depends(get_db)): + db_ms = MilestoneModel(**ms.model_dump()) + db.add(db_ms) + db.commit() + db.refresh(db_ms) + return db_ms + + +@app.get("/milestones", response_model=List[MilestoneResponse]) +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() + + +@app.get("/milestones/{milestone_id}", response_model=MilestoneResponse) +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 + + +@app.patch("/milestones/{milestone_id}", response_model=MilestoneResponse) +def update_milestone(milestone_id: int, ms_update: 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 + + +@app.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) +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 + + +@app.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse]) +def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)): + """List all issues in a milestone.""" + return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + + +@app.get("/milestones/{milestone_id}/progress") +def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): + """Get milestone completion progress.""" + 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() + total = len(issues) + done = sum(1 for i in issues if i.status in ("resolved", "closed")) + return { + "milestone_id": milestone_id, + "title": ms.title, + "total_issues": total, + "completed": done, + "progress_pct": round(done / total * 100, 1) if total else 0, + } + + +# ============ Export API ============ + +import csv +import io +from fastapi.responses import StreamingResponse + + +@app.get("/export/issues") +def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): + """Export issues as CSV.""" + 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", "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.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"} + ) + diff --git a/app/models/milestone.py b/app/models/milestone.py new file mode 100644 index 0000000..8758435 --- /dev/null +++ b/app/models/milestone.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.config import Base +import enum + + +class MilestoneStatus(str, enum.Enum): + OPEN = "open" + CLOSED = "closed" + + +class Milestone(Base): + __tablename__ = "milestones" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + status = Column(Enum(MilestoneStatus), default=MilestoneStatus.OPEN) + due_date = Column(DateTime(timezone=True), nullable=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + project = relationship("Project") diff --git a/app/models/models.py b/app/models/models.py index 6475e97..207d316 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -56,6 +56,10 @@ class Issue(Base): # Dependencies depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) + # Due date and milestone + due_date = Column(DateTime(timezone=True), nullable=True) + milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True) + project = relationship("Project", back_populates="issues") reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 956a67b..88bbf2f 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -34,6 +34,8 @@ class IssueBase(BaseModel): priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM tags: Optional[str] = None depends_on_id: Optional[int] = None + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None class IssueCreate(IssueBase): @@ -54,6 +56,8 @@ class IssueUpdate(BaseModel): assignee_id: Optional[int] = None tags: Optional[str] = None depends_on_id: Optional[int] = None + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None # Resolution specific resolution_summary: Optional[str] = None positions: Optional[str] = None @@ -69,6 +73,8 @@ class IssueResponse(IssueBase): resolution_summary: Optional[str] positions: Optional[str] pending_matters: Optional[str] + due_date: Optional[datetime] = None + milestone_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] @@ -163,3 +169,32 @@ class ProjectMemberResponse(ProjectMemberBase): class Config: from_attributes = True + + +# Milestone schemas +class MilestoneBase(BaseModel): + title: str + description: Optional[str] = None + due_date: Optional[datetime] = None + + +class MilestoneCreate(MilestoneBase): + project_id: int + + +class MilestoneUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + due_date: Optional[datetime] = None + + +class MilestoneResponse(MilestoneBase): + id: int + status: str + project_id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True