feat: milestones, due dates, overdue filter, CSV export

This commit is contained in:
Zhi
2026-02-22 19:12:35 +00:00
parent f48b829511
commit 7485f29ada
4 changed files with 192 additions and 0 deletions

View File

@@ -132,6 +132,19 @@ def list_issues(
return 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) @app.get("/issues/{issue_id}", response_model=schemas.IssueResponse)
def get_issue(issue_id: int, db: Session = Depends(get_db)): def get_issue(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() 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 webhook
from app.models import apikey from app.models import apikey
from app.models import activity from app.models import activity
from app.models import milestone
Base.metadata.create_all(bind=engine) 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: if t:
all_tags.add(t) all_tags.add(t)
return {"tags": sorted(all_tags)} 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"}
)

25
app/models/milestone.py Normal file
View File

@@ -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")

View File

@@ -56,6 +56,10 @@ class Issue(Base):
# Dependencies # Dependencies
depends_on_id = Column(Integer, ForeignKey("issues.id"), nullable=True) 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") project = relationship("Project", back_populates="issues")
reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues") reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues")
assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues") assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues")

View File

@@ -34,6 +34,8 @@ class IssueBase(BaseModel):
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
class IssueCreate(IssueBase): class IssueCreate(IssueBase):
@@ -54,6 +56,8 @@ class IssueUpdate(BaseModel):
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
tags: Optional[str] = None tags: Optional[str] = None
depends_on_id: Optional[int] = None depends_on_id: Optional[int] = None
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
positions: Optional[str] = None positions: Optional[str] = None
@@ -69,6 +73,8 @@ class IssueResponse(IssueBase):
resolution_summary: Optional[str] resolution_summary: Optional[str]
positions: Optional[str] positions: Optional[str]
pending_matters: Optional[str] pending_matters: Optional[str]
due_date: Optional[datetime] = None
milestone_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime]
@@ -163,3 +169,32 @@ class ProjectMemberResponse(ProjectMemberBase):
class Config: class Config:
from_attributes = True 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