feat: Webhook system + CLI tool #2
128
app/main.py
128
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"}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user