feat: milestones, due dates, overdue filter, CSV export
This commit is contained in:
128
app/main.py
128
app/main.py
@@ -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
25
app/models/milestone.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user