Compare commits

...

12 Commits

9 changed files with 824 additions and 54 deletions

View File

@@ -129,11 +129,6 @@ 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()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
return issue return issue
@@ -144,11 +139,6 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session =
check_project_role(db, current_user.id, issue.project_id, min_role="dev") check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(issue, field, value) setattr(issue, field, value)
db.commit() db.commit()
@@ -163,11 +153,6 @@ def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: mod
check_project_role(db, current_user.id, issue.project_id, min_role="mgr") check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title}) log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
db.delete(issue) db.delete(issue)
db.commit() db.commit()
@@ -184,11 +169,6 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
old_status = issue.status old_status = issue.status
issue.status = new_status issue.status = new_status
db.commit() db.commit()
@@ -207,11 +187,6 @@ def assign_issue(issue_id: int, assignee_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()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
user = db.query(models.User).filter(models.User.id == assignee_id).first() user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -266,11 +241,6 @@ def add_tag(issue_id: int, tag: str, 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()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set() current = set(issue.tags.split(",")) if issue.tags else set()
current.add(tag.strip()) current.add(tag.strip())
current.discard("") current.discard("")
@@ -284,11 +254,6 @@ def remove_tag(issue_id: int, tag: str, 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()
if not issue: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set() current = set(issue.tags.split(",")) if issue.tags else set()
current.discard(tag.strip()) current.discard(tag.strip())
current.discard("") current.discard("")

View File

@@ -1,7 +1,9 @@
"""Milestones API router.""" """Milestones API router."""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List, Optional
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey from app.api.deps import get_current_user_or_apikey
@@ -13,23 +15,55 @@ from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"]) router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields."""
result = {
"id": milestone.id,
"title": milestone.title,
"description": milestone.description,
"status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status,
"due_date": milestone.due_date,
"planned_release_date": milestone.planned_release_date,
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id,
"created_at": milestone.created_at,
"updated_at": milestone.updated_at,
}
return result
@router.get("", response_model=List[schemas.MilestoneResponse]) @router.get("", response_model=List[schemas.MilestoneResponse])
def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""List all milestones for a project.""" """List all milestones for a project."""
check_project_role(db, current_user.id, project_id, min_role="viewer") check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return milestones return [_serialize_milestone(m) for m in milestones]
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new milestone for a project.""" """Create a new milestone for a project."""
check_project_role(db, current_user.id, project_id, min_role="mgr") check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = Milestone(project_id=project_id, **milestone.model_dump())
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}"
data = milestone.model_dump()
# Handle JSON fields
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
if data.get("depend_on_tasks"):
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
db_milestone = Milestone(project_id=project_id, milestone_code=milestone_code, **data)
db.add(db_milestone) db.add(db_milestone)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
return db_milestone return _serialize_milestone(db_milestone)
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) @router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -39,7 +73,7 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return milestone return _serialize_milestone(milestone)
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) @router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
@@ -49,11 +83,17 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
for key, value in milestone.model_dump(exclude_unset=True).items(): data = milestone.model_dump(exclude_unset=True)
# Handle JSON fields
if "depend_on_milestones" in data:
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
if "depend_on_tasks" in data:
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"]) if data["depend_on_tasks"] else None
for key, value in data.items():
setattr(db_milestone, key, value) setattr(db_milestone, key, value)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
return db_milestone return _serialize_milestone(db_milestone)
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -66,3 +106,151 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
db.delete(db_milestone) db.delete(db_milestone)
db.commit() db.commit()
return None return None
# Issue type helpers
ISSUE_TYPE_TASK = "task"
ISSUE_TYPE_SUPPORT = "support"
ISSUE_TYPE_MEETING = "meeting"
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED)
def create_milestone_task(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a task under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is in progressing status - cannot add new story
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_TASK
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/supports", status_code=status.HTTP_201_CREATED)
def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a support request under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_SUPPORT
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.post("/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED)
def create_milestone_meeting(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a meeting under a milestone."""
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
issue_data = issue.model_dump()
issue_data["issue_type"] = ISSUE_TYPE_MEETING
issue_data["milestone_id"] = milestone_id
issue_data["project_id"] = project_id
issue_data["reporter_id"] = current_user.id
db_issue = models.Issue(**issue_data)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.get("/{milestone_id}/items")
def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get all items (tasks, supports, meetings) for a milestone."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == ISSUE_TYPE_TASK:
tasks.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_SUPPORT:
supports.append(issue_data)
elif issue.issue_type == ISSUE_TYPE_MEETING:
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
@router.get("/{milestone_id}/progress")
def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get progress for a milestone - tasks only."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
# Count tasks only (not meetings or supports)
all_issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == ISSUE_TYPE_TASK
).all()
total = len(all_issues)
completed = sum(1 for i in all_issues if i.status and hasattr(i.status, 'value') and i.status.value == "closed")
progress_pct = (completed / total * 100) if total > 0 else 0
# Calculate time progress if planned_release_date is set
time_progress = None
if milestone.planned_release_date:
now = datetime.now()
if milestone.created_at and milestone.planned_release_date > milestone.created_at:
total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
elapsed = (now - milestone.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {
"milestone_id": milestone_id,
"title": milestone.title,
"total": total,
"completed": completed,
"progress_pct": round(progress_pct, 1),
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": milestone.planned_release_date,
}

View File

@@ -17,6 +17,9 @@ from app.models import models
from app.models.apikey import APIKey from app.models.apikey import APIKey
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
from app.models.milestone import Milestone as MilestoneModel from app.models.milestone import Milestone as MilestoneModel
from app.models.task import Task, TaskStatus, TaskPriority
from app.models.support import Support, SupportStatus, SupportPriority
from app.models.meeting import Meeting, MeetingStatus, MeetingPriority
from app.models.notification import Notification as NotificationModel from app.models.notification import Notification as NotificationModel
from app.models.worklog import WorkLog from app.models.worklog import WorkLog
from app.schemas import schemas from app.schemas import schemas
@@ -103,8 +106,30 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
# ============ Milestones ============ # ============ Milestones ============
@router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"]) @router.post("/milestones", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db)): def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
db_ms = MilestoneModel(**ms.model_dump()) import json
# Generate milestone_code: projCode:{i:05x}
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
# Get max milestone number for this project
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}"
data = ms.model_dump()
# Serialize list fields to JSON strings
if data.get("depend_on_milestones"):
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
else:
data["depend_on_milestones"] = None
if data.get("depend_on_tasks"):
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
else:
data["depend_on_tasks"] = None
db_ms = MilestoneModel(**data)
db_ms.milestone_code = milestone_code
db.add(db_ms) db.add(db_ms)
db.commit() db.commit()
db.refresh(db_ms) db.refresh(db_ms)
@@ -158,14 +183,179 @@ def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)):
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"]) @router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
from datetime import datetime
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() # Count tasks only
issues = db.query(models.Issue).filter(
models.Issue.milestone_id == milestone_id,
models.Issue.issue_type == "task"
).all()
total = len(issues) total = len(issues)
done = sum(1 for i in issues if i.status in ("resolved", "closed")) 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} time_progress = None
if ms.planned_release_date and ms.created_at:
now = datetime.now()
total_duration = (ms.planned_release_date - ms.created_at).total_seconds()
elapsed = (now - ms.created_at).total_seconds()
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {"milestone_id": milestone_id, "title": ms.title, "total": total,
"completed": done, "progress_pct": round(done / total * 100, 1) if total else 0,
"time_progress_pct": round(time_progress, 1) if time_progress else None,
"planned_release_date": ms.planned_release_date}
@router.get("/milestones/{milestone_id}/items", tags=["Milestones"])
def milestone_items(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")
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
tasks = []
supports = []
meetings = []
for issue in issues:
issue_data = {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
if issue.issue_type == "task":
tasks.append(issue_data)
elif issue.issue_type == "support":
supports.append(issue_data)
elif issue.issue_type == "meeting":
meetings.append(issue_data)
return {"tasks": tasks, "supports": supports, "meetings": meetings}
@router.post("/milestones/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
from datetime import datetime, time
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
# Check if milestone is progressing
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Get project_id from milestone
project_id = ms.project_id
# Generate task_code: i_{project_code}_{id:06x}
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project else f"P{project_id}"
# Get max id for this project to generate unique code
max_issue = db.query(models.Issue).filter(models.Issue.project_id == project_id).order_by(models.Issue.id.desc()).first()
next_id = (max_issue.id + 1) if max_issue else 1
task_code = f"{milestone_code}:T{next_num:05x}"
# Parse estimated_working_time if provided
est_time = None
if issue_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(issue_data["estimated_working_time"], "%H:%M").time()
except:
pass
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="task",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
# Task-specific fields
task_code=task_code,
estimated_effort=issue_data.get("estimated_effort"),
estimated_working_time=est_time,
task_status="open",
created_by_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
# Return with task_code
return {
"id": issue.id,
"title": issue.title,
"description": issue.description,
"task_code": issue.task_code,
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
"created_at": issue.created_at,
}
@router.post("/milestones/{milestone_id}/supports", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_support(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="support",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
@router.post("/milestones/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_meeting(milestone_id: int, issue_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, 'value') and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
project_id = ms.project_id
issue = models.Issue(
title=issue_data.get("title"),
description=issue_data.get("description"),
issue_type="meeting",
status=models.IssueStatus.OPEN,
priority=models.IssuePriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
reporter_id=current_user.id,
)
db.add(issue)
db.commit()
db.refresh(issue)
return issue
# ============ Notifications ============ # ============ Notifications ============
@@ -321,3 +511,292 @@ def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
by_priority = {p: query.filter(models.Issue.priority == p).count() by_priority = {p: query.filter(models.Issue.priority == p).count()
for p in ["low", "medium", "high", "critical"]} for p in ["low", "medium", "high", "critical"]}
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority} return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority}
# ============ Tasks ============
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
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")
tasks = db.query(Task).filter(
Task.project_id == project.id,
Task.milestone_id == milestone_id
).all()
return [{
"id": t.id,
"title": t.title,
"description": t.description,
"status": t.status.value if hasattr(t.status, "value") else t.status,
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
"task_code": t.task_code,
"estimated_effort": t.estimated_effort,
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
"started_on": t.started_on,
"finished_on": t.finished_on,
"depend_on": t.depend_on,
"related_tasks": t.related_tasks,
"assignee_id": t.assignee_id,
"created_at": t.created_at,
} for t in tasks]
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Tasks"])
def create_task(project_code: str, milestone_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
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")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate task_code: milestoneCode:T{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_task = db.query(Task).filter(Task.milestone_id == ms.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
task_code = f"{milestone_code}:T{next_num:05x}"
est_time = None
if task_data.get("estimated_working_time"):
try:
est_time = datetime.strptime(task_data["estimated_working_time"], "%H:%M").time()
except:
pass
task = Task(
title=task_data.get("title"),
description=task_data.get("description"),
status=TaskStatus.OPEN,
priority=TaskPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=task_data.get("estimated_effort"),
estimated_working_time=est_time,
created_by_id=current_user.id,
)
db.add(task)
db.commit()
db.refresh(task)
return {
"id": task.id,
"title": task.title,
"description": task.description,
"task_code": task.task_code,
"status": task.status.value,
"priority": task.priority.value,
"created_at": task.created_at,
}
@router.get("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def get_task(project_code: str, milestone_id: int, task_id: int, db: Session = Depends(get_db)):
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")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {
"id": task.id,
"title": task.title,
"description": task.description,
"status": task.status.value,
"priority": task.priority.value,
"task_code": task.task_code,
"estimated_effort": task.estimated_effort,
"estimated_working_time": str(task.estimated_working_time) if task.estimated_working_time else None,
"started_on": task.started_on,
"finished_on": task.finished_on,
"depend_on": task.depend_on,
"related_tasks": task.related_tasks,
"assignee_id": task.assignee_id,
"created_at": task.created_at,
}
@router.patch("/tasks/{project_code}/{milestone_id}/{task_id}", tags=["Tasks"])
def update_task(project_code: str, milestone_id: int, task_id: int, task_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
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")
task = db.query(Task).filter(
Task.id == task_id,
Task.project_id == project.id,
Task.milestone_id == milestone_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if "title" in task_data:
task.title = task_data["title"]
if "description" in task_data:
task.description = task_data["description"]
if "status" in task_data:
new_status = task_data["status"]
if new_status == "progressing" and not task.started_on:
task.started_on = datetime.now()
if new_status == "closed" and not task.finished_on:
task.finished_on = datetime.now()
task.status = TaskStatus[new_status.upper()] if new_status.upper() in [s.name for s in TaskStatus] else TaskStatus.OPEN
if "priority" in task_data:
task.priority = TaskPriority[task_data["priority"].upper()] if task_data["priority"].upper() in [s.name for s in TaskPriority] else TaskPriority.MEDIUM
if "estimated_effort" in task_data:
task.estimated_effort = task_data["estimated_effort"]
if "assignee_id" in task_data:
task.assignee_id = task_data["assignee_id"]
db.commit()
db.refresh(task)
return task
# ============ Supports ============
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
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")
supports = db.query(Support).filter(
Support.project_id == project.id,
Support.milestone_id == milestone_id
).all()
return [{
"id": s.id,
"title": s.title,
"description": s.description,
"status": s.status.value,
"priority": s.priority.value,
"assignee_id": s.assignee_id,
"created_at": s.created_at,
} for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
def create_support(project_code: str, milestone_id: int, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate support_code: milestoneCode:S{i:05x}
milestone_code = ms.milestone_code or f"m{ms.id}"
max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first()
next_num = (max_support.id + 1) if max_support else 1
support_code = f"{milestone_code}:S{next_num:05x}"
support = Support(
title=support_data.get("title"),
description=support_data.get("description"),
status=SupportStatus.OPEN,
priority=SupportPriority.MEDIUM,
project_id=project.id,
milestone_id=milestone_id,
reporter_id=current_user.id,
support_code=support_code,
)
db.add(support)
db.commit()
db.refresh(support)
return support
# ============ Meetings ============
@router.get("/meetings/{project_code}/{milestone_id}", tags=["Meetings"])
def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
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")
meetings = db.query(Meeting).filter(
Meeting.project_id == project.id,
Meeting.milestone_id == milestone_id
).all()
return [{
"id": m.id,
"title": m.title,
"description": m.description,
"status": m.status.value,
"priority": m.priority.value,
"scheduled_at": m.scheduled_at,
"duration_minutes": m.duration_minutes,
"created_at": m.created_at,
} for m in meetings]
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Meetings"])
def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
from datetime import datetime
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")
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "progressing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
# Generate meeting_code: milestoneCode:M{i:05x}
milestone_code = ms.milestone_code or f"m{ms.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 meeting_data.get("scheduled_at"):
try:
scheduled_at = datetime.fromisoformat(meeting_data["scheduled_at"].replace("Z", "+00:00"))
except:
pass
meeting = Meeting(
title=meeting_data.get("title"),
description=meeting_data.get("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=meeting_data.get("duration_minutes"),
)
db.add(meeting)
db.commit()
db.refresh(meeting)
return meeting

37
app/models/meeting.py Normal file
View File

@@ -0,0 +1,37 @@
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 MeetingStatus(str, enum.Enum):
SCHEDULED = "scheduled"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class MeetingPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Meeting(Base):
__tablename__ = "meetings"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(MeetingStatus), default=MeetingStatus.SCHEDULED)
priority = Column(Enum(MeetingPriority), default=MeetingPriority.MEDIUM)
meeting_code = Column(String(64), nullable=True, unique=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
scheduled_at = Column(DateTime(timezone=True), nullable=True)
duration_minutes = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -1,25 +1,30 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Time
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.config import Base from app.core.config import Base
import enum import enum
class MilestoneStatus(str, enum.Enum): class MilestoneStatus(str, enum.Enum):
OPEN = "open" OPEN = "open"
PENDING = "pending"
DEFERRED = "deferred"
PROGRESSING = "progressing"
CLOSED = "closed" CLOSED = "closed"
class Milestone(Base): class Milestone(Base):
__tablename__ = "milestones" __tablename__ = "milestones"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
status = Column(Enum(MilestoneStatus), default=MilestoneStatus.OPEN) status = Column(Enum(MilestoneStatus), default=MilestoneStatus.OPEN)
milestone_code = Column(String(64), nullable=True, unique=True, index=True)
due_date = Column(DateTime(timezone=True), nullable=True) due_date = Column(DateTime(timezone=True), nullable=True)
planned_release_date = Column(DateTime(timezone=True), nullable=True)
depend_on_milestones = Column(Text, nullable=True)
depend_on_tasks = Column(Text, nullable=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
project = relationship("Project") project = relationship("Project")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.config import Base from app.core.config import Base
@@ -68,6 +68,17 @@ class Issue(Base):
due_date = Column(DateTime(timezone=True), nullable=True) due_date = Column(DateTime(timezone=True), nullable=True)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True) milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=True)
# Task-specific fields
task_code = Column(String(64), nullable=True, unique=True, index=True)
depend_on = Column(Text, nullable=True) # JSON list of task codes
estimated_effort = Column(Integer, nullable=True) # 1-10
estimated_working_time = Column(Time(timezone=True), nullable=True)
task_status = Column(String(32), default="open") # open, closed, pending, progressing
started_on = Column(DateTime(timezone=True), nullable=True)
finished_on = Column(DateTime(timezone=True), nullable=True)
related_tasks = Column(Text, nullable=True) # JSON list of task codes
created_by_id = Column(Integer, ForeignKey("users.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")

35
app/models/support.py Normal file
View File

@@ -0,0 +1,35 @@
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 SupportStatus(str, enum.Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
class SupportPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Support(Base):
__tablename__ = "supports"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(SupportStatus), default=SupportStatus.OPEN)
priority = Column(Enum(SupportPriority), default=SupportPriority.MEDIUM)
support_code = Column(String(64), nullable=True, unique=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

43
app/models/task.py Normal file
View File

@@ -0,0 +1,43 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Time
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
import enum
class TaskStatus(str, enum.Enum):
OPEN = "open"
PENDING = "pending"
PROGRESSING = "progressing"
CLOSED = "closed"
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
status = Column(Enum(TaskStatus), default=TaskStatus.OPEN)
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM)
task_code = Column(String(64), nullable=True, unique=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
depend_on = Column(Text, nullable=True)
estimated_effort = Column(Integer, nullable=True)
estimated_working_time = Column(Time, nullable=True)
started_on = Column(DateTime(timezone=True), nullable=True)
finished_on = Column(DateTime(timezone=True), nullable=True)
related_tasks = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -203,10 +203,15 @@ class ProjectMemberResponse(BaseModel):
class MilestoneBase(BaseModel): class MilestoneBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = "open"
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
planned_release_date: Optional[datetime] = None
depend_on_milestones: Optional[List[str]] = None
depend_on_tasks: Optional[List[int]] = None
class MilestoneCreate(MilestoneBase): class MilestoneCreate(MilestoneBase):
project_id: int
pass pass
@@ -215,11 +220,13 @@ class MilestoneUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
planned_release_date: Optional[datetime] = None
depend_on_milestones: Optional[List[str]] = None
depend_on_tasks: Optional[List[int]] = None
class MilestoneResponse(MilestoneBase): class MilestoneResponse(MilestoneBase):
id: int id: int
status: str
project_id: int project_id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None