refactor: replace issues backend with milestone tasks
This commit is contained in:
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
from app.models import models
|
from app.models import models
|
||||||
|
from app.models.task import Task
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
from app.api.deps import get_current_user_or_apikey
|
from app.api.deps import get_current_user_or_apikey
|
||||||
from app.api.rbac import check_project_role
|
from app.api.rbac import check_project_role
|
||||||
@@ -13,25 +14,24 @@ from app.models.notification import Notification as NotificationModel
|
|||||||
router = APIRouter(tags=["Comments"])
|
router = APIRouter(tags=["Comments"])
|
||||||
|
|
||||||
|
|
||||||
def _notify_if_needed(db, issue_id, user_ids, ntype, title):
|
def _notify_if_needed(db, task_id, user_ids, ntype, title):
|
||||||
"""Helper to notify multiple users."""
|
"""Helper to notify multiple users."""
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not issue:
|
if not task:
|
||||||
return
|
return
|
||||||
for uid in set(user_ids):
|
for uid in set(user_ids):
|
||||||
if uid:
|
if uid:
|
||||||
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id)
|
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="task", entity_id=task_id)
|
||||||
db.add(n)
|
db.add(n)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
# Get project_id from issue first
|
task = db.query(Task).filter(Task.id == comment.task_id).first()
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
if not task:
|
||||||
if not issue:
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
|
|
||||||
|
|
||||||
db_comment = models.Comment(**comment.model_dump())
|
db_comment = models.Comment(**comment.model_dump())
|
||||||
db.add(db_comment)
|
db.add(db_comment)
|
||||||
@@ -40,19 +40,19 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
|
|||||||
|
|
||||||
# Notify reporter and assignee (but not the commenter themselves)
|
# Notify reporter and assignee (but not the commenter themselves)
|
||||||
notify_users = []
|
notify_users = []
|
||||||
if issue.reporter_id != current_user.id:
|
if task.reporter_id != current_user.id:
|
||||||
notify_users.append(issue.reporter_id)
|
notify_users.append(task.reporter_id)
|
||||||
if issue.assignee_id and issue.assignee_id != current_user.id:
|
if task.assignee_id and task.assignee_id != current_user.id:
|
||||||
notify_users.append(issue.assignee_id)
|
notify_users.append(task.assignee_id)
|
||||||
if notify_users:
|
if notify_users:
|
||||||
_notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}")
|
_notify_if_needed(db, task.id, notify_users, "comment_added", f"New comment on: {task.title[:50]}")
|
||||||
|
|
||||||
return db_comment
|
return db_comment
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/{issue_id}/comments", response_model=List[schemas.CommentResponse])
|
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse])
|
||||||
def list_comments(issue_id: int, db: Session = Depends(get_db)):
|
def list_comments(task_id: int, db: Session = Depends(get_db)):
|
||||||
return db.query(models.Comment).filter(models.Comment.issue_id == issue_id).all()
|
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
|
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
|
||||||
@@ -60,10 +60,10 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S
|
|||||||
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
||||||
if not comment:
|
if not comment:
|
||||||
raise HTTPException(status_code=404, detail="Comment not found")
|
raise HTTPException(status_code=404, detail="Comment not found")
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
task = db.query(Task).filter(Task.id == comment.task_id).first()
|
||||||
if not issue:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
|
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
|
||||||
for field, value in comment_update.model_dump(exclude_unset=True).items():
|
for field, value in comment_update.model_dump(exclude_unset=True).items():
|
||||||
setattr(comment, field, value)
|
setattr(comment, field, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -76,11 +76,10 @@ def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user:
|
|||||||
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
||||||
if not comment:
|
if not comment:
|
||||||
raise HTTPException(status_code=404, detail="Comment not found")
|
raise HTTPException(status_code=404, detail="Comment not found")
|
||||||
# Get issue to check project role
|
task = db.query(Task).filter(Task.id == comment.task_id).first()
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
if not task:
|
||||||
if not issue:
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
check_project_role(db, current_user.id, task.project_id, min_role="dev")
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
|
||||||
db.delete(comment)
|
db.delete(comment)
|
||||||
db.commit()
|
db.commit()
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
"""Issues router."""
|
|
||||||
import math
|
|
||||||
from typing import List
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.core.config import get_db
|
|
||||||
from app.models import models
|
|
||||||
from app.schemas import schemas
|
|
||||||
from app.services.webhook import fire_webhooks_sync
|
|
||||||
from app.models.notification import Notification as NotificationModel
|
|
||||||
from app.api.deps import get_current_user_or_apikey
|
|
||||||
from app.api.rbac import check_project_role
|
|
||||||
from app.services.activity import log_activity
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Issues"])
|
|
||||||
|
|
||||||
# ---- Type / Subtype validation ----
|
|
||||||
ISSUE_SUBTYPE_MAP = {
|
|
||||||
'meeting': {'conference', 'handover', 'recap'},
|
|
||||||
'support': {'access', 'information'},
|
|
||||||
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
|
|
||||||
'maintenance': {'deploy', 'release'},
|
|
||||||
'review': {'code_review', 'decision_review', 'function_review'},
|
|
||||||
'story': {'feature', 'improvement', 'refactor'},
|
|
||||||
'test': {'regression', 'security', 'smoke', 'stress'},
|
|
||||||
'research': set(),
|
|
||||||
'task': {'defect'},
|
|
||||||
'resolution': set(),
|
|
||||||
}
|
|
||||||
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
|
|
||||||
if issue_type is None:
|
|
||||||
raise HTTPException(status_code=400, detail='issue_type is required')
|
|
||||||
if issue_type not in ALLOWED_ISSUE_TYPES:
|
|
||||||
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
|
|
||||||
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
|
|
||||||
if issue_subtype:
|
|
||||||
if issue_subtype not in allowed:
|
|
||||||
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
|
|
||||||
else:
|
|
||||||
if require_subtype and allowed:
|
|
||||||
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
|
|
||||||
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
|
|
||||||
entity_type=entity_type, entity_id=entity_id)
|
|
||||||
db.add(n)
|
|
||||||
db.commit()
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
# ---- CRUD ----
|
|
||||||
|
|
||||||
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
|
||||||
db_issue = models.Issue(**issue.model_dump())
|
|
||||||
db.add(db_issue)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_issue)
|
|
||||||
event = "resolution.created" if db_issue.issue_type == "resolution" else "issue.created"
|
|
||||||
bg.add_task(fire_webhooks_sync, event,
|
|
||||||
{"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
|
|
||||||
db_issue.project_id, db)
|
|
||||||
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
|
|
||||||
return db_issue
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues")
|
|
||||||
def list_issues(
|
|
||||||
project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
|
|
||||||
assignee_id: int = None, tag: str = None,
|
|
||||||
sort_by: str = "created_at", sort_order: str = "desc",
|
|
||||||
page: int = 1, page_size: int = 50,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""List issues with filtering, sorting, and pagination metadata."""
|
|
||||||
query = db.query(models.Issue)
|
|
||||||
if project_id:
|
|
||||||
query = query.filter(models.Issue.project_id == project_id)
|
|
||||||
if issue_status:
|
|
||||||
query = query.filter(models.Issue.status == issue_status)
|
|
||||||
if issue_type:
|
|
||||||
query = query.filter(models.Issue.issue_type == issue_type)
|
|
||||||
if issue_subtype:
|
|
||||||
query = query.filter(models.Issue.issue_subtype == issue_subtype)
|
|
||||||
if assignee_id:
|
|
||||||
query = query.filter(models.Issue.assignee_id == assignee_id)
|
|
||||||
if tag:
|
|
||||||
query = query.filter(models.Issue.tags.contains(tag))
|
|
||||||
|
|
||||||
sort_fields = {
|
|
||||||
"created_at": models.Issue.created_at, "updated_at": models.Issue.updated_at,
|
|
||||||
"priority": models.Issue.priority, "title": models.Issue.title,
|
|
||||||
"due_date": models.Issue.due_date, "status": models.Issue.status,
|
|
||||||
}
|
|
||||||
sort_col = sort_fields.get(sort_by, models.Issue.created_at)
|
|
||||||
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
page = max(1, page)
|
|
||||||
page_size = min(max(1, page_size), 200)
|
|
||||||
total_pages = math.ceil(total / page_size) if total else 1
|
|
||||||
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
|
||||||
return {"items": [schemas.IssueResponse.model_validate(i) for i in items],
|
|
||||||
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/overdue", response_model=List[schemas.IssueResponse])
|
|
||||||
def list_overdue_issues(project_id: int = None, db: Session = Depends(get_db)):
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@router.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()
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
return issue
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse)
|
|
||||||
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
|
||||||
if issue:
|
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(issue, field, value)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(issue)
|
|
||||||
return issue
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
|
||||||
if issue:
|
|
||||||
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
|
|
||||||
db.delete(issue)
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Transition ----
|
|
||||||
|
|
||||||
@router.post("/issues/{issue_id}/transition", response_model=schemas.IssueResponse)
|
|
||||||
def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
|
||||||
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
|
|
||||||
if new_status not in valid_statuses:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
|
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
old_status = issue.status
|
|
||||||
issue.status = new_status
|
|
||||||
db.commit()
|
|
||||||
db.refresh(issue)
|
|
||||||
event = "issue.closed" if new_status == "closed" else "issue.updated"
|
|
||||||
bg.add_task(fire_webhooks_sync, event,
|
|
||||||
{"issue_id": issue.id, "title": issue.title, "old_status": old_status, "new_status": new_status},
|
|
||||||
issue.project_id, db)
|
|
||||||
return issue
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Assignment ----
|
|
||||||
|
|
||||||
@router.post("/issues/{issue_id}/assign")
|
|
||||||
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()
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
issue.assignee_id = assignee_id
|
|
||||||
db.commit()
|
|
||||||
db.refresh(issue)
|
|
||||||
_notify_user(db, assignee_id, "issue.assigned",
|
|
||||||
f"Issue #{issue.id} assigned to you",
|
|
||||||
f"'{issue.title}' has been assigned to you.", "issue", issue.id)
|
|
||||||
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Relations ----
|
|
||||||
|
|
||||||
class IssueRelation(BaseModel):
|
|
||||||
parent_id: int
|
|
||||||
child_id: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/issues/link")
|
|
||||||
def link_issues(rel: IssueRelation, db: Session = Depends(get_db)):
|
|
||||||
parent = db.query(models.Issue).filter(models.Issue.id == rel.parent_id).first()
|
|
||||||
child = db.query(models.Issue).filter(models.Issue.id == rel.child_id).first()
|
|
||||||
if not parent or not child:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
if rel.parent_id == rel.child_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot link issue to itself")
|
|
||||||
child.depends_on_id = rel.parent_id
|
|
||||||
db.commit()
|
|
||||||
return {"parent_id": rel.parent_id, "child_id": rel.child_id, "status": "linked"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/issues/link")
|
|
||||||
def unlink_issues(child_id: int, db: Session = Depends(get_db)):
|
|
||||||
child = db.query(models.Issue).filter(models.Issue.id == child_id).first()
|
|
||||||
if not child:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
child.depends_on_id = None
|
|
||||||
db.commit()
|
|
||||||
return {"child_id": child_id, "status": "unlinked"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/{issue_id}/children", response_model=List[schemas.IssueResponse])
|
|
||||||
def get_children(issue_id: int, db: Session = Depends(get_db)):
|
|
||||||
return db.query(models.Issue).filter(models.Issue.depends_on_id == issue_id).all()
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Tags ----
|
|
||||||
|
|
||||||
@router.post("/issues/{issue_id}/tags")
|
|
||||||
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()
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
current = set(issue.tags.split(",")) if issue.tags else set()
|
|
||||||
current.add(tag.strip())
|
|
||||||
current.discard("")
|
|
||||||
issue.tags = ",".join(sorted(current))
|
|
||||||
db.commit()
|
|
||||||
return {"issue_id": issue_id, "tags": list(current)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/issues/{issue_id}/tags")
|
|
||||||
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()
|
|
||||||
if not issue:
|
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
|
||||||
current = set(issue.tags.split(",")) if issue.tags else set()
|
|
||||||
current.discard(tag.strip())
|
|
||||||
current.discard("")
|
|
||||||
issue.tags = ",".join(sorted(current)) if current else None
|
|
||||||
db.commit()
|
|
||||||
return {"issue_id": issue_id, "tags": list(current)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tags")
|
|
||||||
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
|
|
||||||
query = db.query(models.Issue.tags).filter(models.Issue.tags != None)
|
|
||||||
if project_id:
|
|
||||||
query = query.filter(models.Issue.project_id == project_id)
|
|
||||||
all_tags = set()
|
|
||||||
for (tags,) in query.all():
|
|
||||||
for t in tags.split(","):
|
|
||||||
t = t.strip()
|
|
||||||
if t:
|
|
||||||
all_tags.add(t)
|
|
||||||
return {"tags": sorted(all_tags)}
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Batch ----
|
|
||||||
|
|
||||||
class BatchTransition(BaseModel):
|
|
||||||
issue_ids: List[int]
|
|
||||||
new_status: str
|
|
||||||
|
|
||||||
class BatchAssign(BaseModel):
|
|
||||||
issue_ids: List[int]
|
|
||||||
assignee_id: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/issues/batch/transition")
|
|
||||||
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
|
||||||
valid_statuses = ["open", "in_progress", "resolved", "closed", "blocked"]
|
|
||||||
if data.new_status not in valid_statuses:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
|
||||||
updated = []
|
|
||||||
for issue_id in data.issue_ids:
|
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
|
||||||
if issue:
|
|
||||||
old_status = issue.status
|
|
||||||
issue.status = data.new_status
|
|
||||||
updated.append({"id": issue.id, "title": issue.title, "old": old_status, "new": data.new_status})
|
|
||||||
db.commit()
|
|
||||||
for u in updated:
|
|
||||||
event = "issue.closed" if data.new_status == "closed" else "issue.updated"
|
|
||||||
bg.add_task(fire_webhooks_sync, event, u, None, db)
|
|
||||||
return {"updated": len(updated), "issues": updated}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/issues/batch/assign")
|
|
||||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
|
||||||
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
|
||||||
updated = []
|
|
||||||
for issue_id in data.issue_ids:
|
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
|
||||||
if issue:
|
|
||||||
issue.assignee_id = data.assignee_id
|
|
||||||
updated.append(issue_id)
|
|
||||||
db.commit()
|
|
||||||
return {"updated": len(updated), "issue_ids": updated, "assignee_id": data.assignee_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Search ----
|
|
||||||
|
|
||||||
@router.get("/search/issues")
|
|
||||||
def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
|
|
||||||
db: Session = Depends(get_db)):
|
|
||||||
query = db.query(models.Issue).filter(
|
|
||||||
(models.Issue.title.contains(q)) | (models.Issue.description.contains(q))
|
|
||||||
)
|
|
||||||
if project_id:
|
|
||||||
query = query.filter(models.Issue.project_id == project_id)
|
|
||||||
total = query.count()
|
|
||||||
page = max(1, page)
|
|
||||||
page_size = min(max(1, page_size), 200)
|
|
||||||
total_pages = math.ceil(total / page_size) if total else 1
|
|
||||||
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
|
||||||
return {"items": [schemas.IssueResponse.model_validate(i) for i in items],
|
|
||||||
"total": total, "page": page, "page_size": page_size, "total_pages": total_pages}
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Milestones API router."""
|
"""Milestones API router (project-scoped)."""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
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, Optional
|
from typing import List
|
||||||
|
|
||||||
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
|
||||||
from app.api.rbac import check_project_role
|
from app.api.rbac import check_project_role
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.task import Task, TaskStatus, TaskPriority
|
||||||
|
from app.models.support import Support
|
||||||
|
from app.models.meeting import Meeting
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
|
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
|
||||||
@@ -17,7 +20,7 @@ router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones
|
|||||||
|
|
||||||
def _serialize_milestone(milestone):
|
def _serialize_milestone(milestone):
|
||||||
"""Serialize milestone with JSON fields."""
|
"""Serialize milestone with JSON fields."""
|
||||||
result = {
|
return {
|
||||||
"id": milestone.id,
|
"id": milestone.id,
|
||||||
"title": milestone.title,
|
"title": milestone.title,
|
||||||
"description": milestone.description,
|
"description": milestone.description,
|
||||||
@@ -30,12 +33,10 @@ def _serialize_milestone(milestone):
|
|||||||
"created_at": milestone.created_at,
|
"created_at": milestone.created_at,
|
||||||
"updated_at": milestone.updated_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."""
|
|
||||||
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 [_serialize_milestone(m) for m in milestones]
|
return [_serialize_milestone(m) for m in milestones]
|
||||||
@@ -43,10 +44,8 @@ def list_milestones(project_id: int, db: Session = Depends(get_db), current_user
|
|||||||
|
|
||||||
@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."""
|
|
||||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||||
|
|
||||||
# Generate milestone_code: projCode:{i:05x}
|
|
||||||
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
project = db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||||
project_code = project.project_code if project else f"P{project_id}"
|
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()
|
max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first()
|
||||||
@@ -54,9 +53,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
|
|||||||
milestone_code = f"{project_code}:{next_num:05x}"
|
milestone_code = f"{project_code}:{next_num:05x}"
|
||||||
|
|
||||||
data = milestone.model_dump()
|
data = milestone.model_dump()
|
||||||
# Remove project_id from data if present (it's already in the URL path)
|
|
||||||
data.pop('project_id', None)
|
data.pop('project_id', None)
|
||||||
# Handle JSON fields
|
|
||||||
if data.get("depend_on_milestones"):
|
if data.get("depend_on_milestones"):
|
||||||
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
|
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
|
||||||
if data.get("depend_on_tasks"):
|
if data.get("depend_on_tasks"):
|
||||||
@@ -70,7 +67,6 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
|
|||||||
|
|
||||||
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
||||||
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
"""Get a milestone by ID."""
|
|
||||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
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()
|
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
||||||
if not milestone:
|
if not milestone:
|
||||||
@@ -80,13 +76,11 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_
|
|||||||
|
|
||||||
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
|
||||||
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
"""Update a milestone."""
|
|
||||||
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 = 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")
|
||||||
data = milestone.model_dump(exclude_unset=True)
|
data = milestone.model_dump(exclude_unset=True)
|
||||||
# Handle JSON fields
|
|
||||||
if "depend_on_milestones" in data:
|
if "depend_on_milestones" in data:
|
||||||
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
|
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
|
||||||
if "depend_on_tasks" in data:
|
if "depend_on_tasks" in data:
|
||||||
@@ -100,7 +94,6 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
|
|||||||
|
|
||||||
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
"""Delete a milestone."""
|
|
||||||
check_project_role(db, current_user.id, project_id, min_role="admin")
|
check_project_role(db, current_user.id, project_id, min_role="admin")
|
||||||
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:
|
||||||
@@ -110,39 +103,8 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Issue type helpers
|
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
|
||||||
ISSUE_TYPE_TASK = "task"
|
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
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")
|
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()
|
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
||||||
if not milestone:
|
if not milestone:
|
||||||
@@ -151,98 +113,86 @@ def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.
|
|||||||
if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing":
|
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")
|
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress")
|
||||||
|
|
||||||
issue_data = issue.model_dump()
|
# Generate task_code
|
||||||
issue_data["issue_type"] = ISSUE_TYPE_SUPPORT
|
milestone_code = milestone.milestone_code or f"m{milestone.id}"
|
||||||
issue_data["milestone_id"] = milestone_id
|
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
|
||||||
issue_data["project_id"] = project_id
|
next_num = (max_task.id + 1) if max_task else 1
|
||||||
issue_data["reporter_id"] = current_user.id
|
task_code = f"{milestone_code}:T{next_num:05x}"
|
||||||
db_issue = models.Issue(**issue_data)
|
|
||||||
db.add(db_issue)
|
est_time = None
|
||||||
|
data = task_data.model_dump(exclude_unset=True)
|
||||||
|
if data.get("estimated_working_time"):
|
||||||
|
try:
|
||||||
|
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
title=data.get("title"),
|
||||||
|
description=data.get("description"),
|
||||||
|
task_type=data.get("task_type", "task"),
|
||||||
|
task_subtype=data.get("task_subtype"),
|
||||||
|
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=data.get("estimated_effort"),
|
||||||
|
estimated_working_time=est_time,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_issue)
|
db.refresh(task)
|
||||||
return db_issue
|
return task
|
||||||
|
|
||||||
|
|
||||||
@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")
|
@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)):
|
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")
|
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()
|
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")
|
||||||
|
|
||||||
issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
|
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
|
||||||
|
supports = db.query(Support).filter(Support.milestone_id == milestone_id).all()
|
||||||
|
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
|
||||||
|
|
||||||
tasks = []
|
return {
|
||||||
supports = []
|
"tasks": [{
|
||||||
meetings = []
|
"id": t.id, "title": t.title, "description": t.description,
|
||||||
|
"status": t.status.value if hasattr(t.status, 'value') else t.status,
|
||||||
for issue in issues:
|
"priority": t.priority.value if hasattr(t.priority, 'value') else t.priority,
|
||||||
issue_data = {
|
"task_code": t.task_code, "created_at": t.created_at,
|
||||||
"id": issue.id,
|
} for t in tasks],
|
||||||
"title": issue.title,
|
"supports": [{
|
||||||
"description": issue.description,
|
"id": s.id, "title": s.title, "description": s.description,
|
||||||
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
|
"status": s.status.value, "priority": s.priority.value, "created_at": s.created_at,
|
||||||
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
|
} for s in supports],
|
||||||
"created_at": issue.created_at,
|
"meetings": [{
|
||||||
}
|
"id": m.id, "title": m.title, "description": m.description,
|
||||||
if issue.issue_type == ISSUE_TYPE_TASK:
|
"status": m.status.value, "priority": m.priority.value, "created_at": m.created_at,
|
||||||
tasks.append(issue_data)
|
} for m in meetings],
|
||||||
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")
|
@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)):
|
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")
|
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()
|
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")
|
||||||
|
|
||||||
# Count tasks only (not meetings or supports)
|
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
|
||||||
all_issues = db.query(models.Issue).filter(
|
total = len(all_tasks)
|
||||||
models.Issue.milestone_id == milestone_id,
|
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
|
||||||
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
|
progress_pct = (completed / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
# Calculate time progress if planned_release_date is set
|
|
||||||
time_progress = None
|
time_progress = None
|
||||||
if milestone.planned_release_date:
|
if milestone.planned_release_date and milestone.created_at:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if milestone.created_at and milestone.planned_release_date > milestone.created_at:
|
if milestone.planned_release_date > milestone.created_at:
|
||||||
total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
|
total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds()
|
||||||
elapsed = (now - milestone.created_at).total_seconds()
|
elapsed = (now - milestone.created_at).total_seconds()
|
||||||
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
||||||
@@ -251,6 +201,7 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
|
|||||||
"milestone_id": milestone_id,
|
"milestone_id": milestone_id,
|
||||||
"title": milestone.title,
|
"title": milestone.title,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"total_tasks": total,
|
||||||
"completed": completed,
|
"completed": completed,
|
||||||
"progress_pct": round(progress_pct, 1),
|
"progress_pct": round(progress_pct, 1),
|
||||||
"time_progress_pct": round(time_progress, 1) if time_progress else None,
|
"time_progress_pct": round(time_progress, 1) if time_progress else None,
|
||||||
|
|||||||
@@ -103,22 +103,19 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
|
|||||||
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
|
return query.order_by(ActivityLog.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
# ============ Milestones ============
|
# ============ Milestones (top-level, non project-scoped) ============
|
||||||
|
|
||||||
@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), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
import json
|
import json
|
||||||
# Generate milestone_code: projCode:{i:05x}
|
|
||||||
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
|
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}"
|
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()
|
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
|
next_num = (max_ms.id + 1) if max_ms else 1
|
||||||
milestone_code = f"{project_code}:{next_num:05x}"
|
milestone_code = f"{project_code}:{next_num:05x}"
|
||||||
|
|
||||||
data = ms.model_dump()
|
data = ms.model_dump()
|
||||||
# Serialize list fields to JSON strings
|
|
||||||
if data.get("depend_on_milestones"):
|
if data.get("depend_on_milestones"):
|
||||||
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
|
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
|
||||||
else:
|
else:
|
||||||
@@ -176,24 +173,14 @@ def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/milestones/{milestone_id}/issues", response_model=List[schemas.IssueResponse], tags=["Milestones"])
|
|
||||||
def list_milestone_issues(milestone_id: int, db: Session = Depends(get_db)):
|
|
||||||
return db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all()
|
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
# Count tasks only
|
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
|
||||||
issues = db.query(models.Issue).filter(
|
total = len(tasks)
|
||||||
models.Issue.milestone_id == milestone_id,
|
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
|
||||||
models.Issue.issue_type == "task"
|
|
||||||
).all()
|
|
||||||
total = len(issues)
|
|
||||||
done = sum(1 for i in issues if i.status in ("resolved", "closed"))
|
|
||||||
|
|
||||||
time_progress = None
|
time_progress = None
|
||||||
if ms.planned_release_date and ms.created_at:
|
if ms.planned_release_date and ms.created_at:
|
||||||
@@ -202,162 +189,18 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
|
|||||||
elapsed = (now - ms.created_at).total_seconds()
|
elapsed = (now - ms.created_at).total_seconds()
|
||||||
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
|
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 {
|
return {
|
||||||
"id": issue.id,
|
"milestone_id": milestone_id,
|
||||||
"title": issue.title,
|
"title": ms.title,
|
||||||
"description": issue.description,
|
"total": total,
|
||||||
"task_code": issue.task_code,
|
"total_tasks": total,
|
||||||
"status": issue.status.value if hasattr(issue.status, 'value') else issue.status,
|
"completed": done,
|
||||||
"priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority,
|
"progress_pct": round(done / total * 100, 1) if total else 0,
|
||||||
"created_at": issue.created_at,
|
"time_progress_pct": round(time_progress, 1) if time_progress else None,
|
||||||
|
"planned_release_date": ms.planned_release_date,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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 ============
|
||||||
|
|
||||||
class NotificationResponse(BaseModel):
|
class NotificationResponse(BaseModel):
|
||||||
@@ -368,10 +211,24 @@ class NotificationResponse(BaseModel):
|
|||||||
message: str | None = None
|
message: str | None = None
|
||||||
entity_type: str | None = None
|
entity_type: str | None = None
|
||||||
entity_id: int | None = None
|
entity_id: int | None = None
|
||||||
|
task_id: int | None = None
|
||||||
is_read: bool
|
is_read: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
def _serialize_notification(notification: NotificationModel):
|
||||||
|
return {
|
||||||
|
"id": notification.id,
|
||||||
|
"user_id": notification.user_id,
|
||||||
|
"type": notification.type,
|
||||||
|
"title": notification.title,
|
||||||
|
"message": notification.message or notification.title,
|
||||||
|
"entity_type": notification.entity_type,
|
||||||
|
"entity_id": notification.entity_id,
|
||||||
|
"task_id": notification.entity_id if notification.entity_type == "task" else None,
|
||||||
|
"is_read": notification.is_read,
|
||||||
|
"created_at": notification.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
|
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
|
||||||
@@ -379,7 +236,8 @@ def list_notifications(unread_only: bool = False, limit: int = 50, db: Session =
|
|||||||
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
|
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
|
||||||
if unread_only:
|
if unread_only:
|
||||||
query = query.filter(NotificationModel.is_read == False)
|
query = query.filter(NotificationModel.is_read == False)
|
||||||
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
|
notifications = query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
|
||||||
|
return [_serialize_notification(n) for n in notifications]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifications/count", tags=["Notifications"])
|
@router.get("/notifications/count", tags=["Notifications"])
|
||||||
@@ -414,7 +272,7 @@ def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Dep
|
|||||||
# ============ Work Logs ============
|
# ============ Work Logs ============
|
||||||
|
|
||||||
class WorkLogCreate(BaseModel):
|
class WorkLogCreate(BaseModel):
|
||||||
issue_id: int
|
task_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
hours: float
|
hours: float
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -422,7 +280,7 @@ class WorkLogCreate(BaseModel):
|
|||||||
|
|
||||||
class WorkLogResponse(BaseModel):
|
class WorkLogResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
issue_id: int
|
task_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
hours: float
|
hours: float
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -434,9 +292,9 @@ class WorkLogResponse(BaseModel):
|
|||||||
|
|
||||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
||||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first()
|
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
||||||
if not issue:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
|
user = db.query(models.User).filter(models.User.id == wl.user_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")
|
||||||
@@ -449,19 +307,19 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
|||||||
return db_wl
|
return db_wl
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
|
@router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
|
||||||
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)):
|
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)):
|
||||||
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all()
|
return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/{issue_id}/worklogs/summary", tags=["Time Tracking"])
|
@router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
|
||||||
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)):
|
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)):
|
||||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if not issue:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Issue not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0
|
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0
|
||||||
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count()
|
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count()
|
||||||
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count}
|
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
||||||
@@ -476,47 +334,55 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# ============ Export ============
|
# ============ Export ============
|
||||||
|
|
||||||
@router.get("/export/issues", tags=["Export"])
|
@router.get("/export/tasks", tags=["Export"])
|
||||||
def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
|
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||||
query = db.query(models.Issue)
|
query = db.query(Task)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(models.Issue.project_id == project_id)
|
query = query.filter(Task.project_id == project_id)
|
||||||
issues = query.all()
|
tasks = query.all()
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output)
|
writer = csv.writer(output)
|
||||||
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
|
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
|
||||||
"reporter_id", "assignee_id", "milestone_id", "due_date",
|
"milestone_id", "reporter_id", "assignee_id", "task_code",
|
||||||
"tags", "created_at", "updated_at"])
|
"tags", "created_at", "updated_at"])
|
||||||
for i in issues:
|
for t in tasks:
|
||||||
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
|
writer.writerow([t.id, t.title, t.task_type, t.task_subtype or "",
|
||||||
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
|
t.status.value if hasattr(t.status, 'value') else t.status,
|
||||||
i.tags, i.created_at, i.updated_at])
|
t.priority.value if hasattr(t.priority, 'value') else t.priority,
|
||||||
|
t.project_id, t.milestone_id, t.reporter_id, t.assignee_id, t.task_code,
|
||||||
|
t.tags, t.created_at, t.updated_at])
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
|
return StreamingResponse(iter([output.getvalue()]), media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=issues.csv"})
|
headers={"Content-Disposition": "attachment; filename=tasks.csv"})
|
||||||
|
|
||||||
|
|
||||||
# ============ Dashboard ============
|
# ============ Dashboard ============
|
||||||
|
|
||||||
@router.get("/dashboard/stats", tags=["Dashboard"])
|
@router.get("/dashboard/stats", tags=["Dashboard"])
|
||||||
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
|
def dashboard_stats(project_id: int = None, db: Session = Depends(get_db)):
|
||||||
query = db.query(models.Issue)
|
query = db.query(Task)
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(models.Issue.project_id == project_id)
|
query = query.filter(Task.project_id == project_id)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
by_status = {s: query.filter(models.Issue.status == s).count()
|
by_status = {s.value: query.filter(Task.status == s).count() for s in TaskStatus}
|
||||||
for s in ["open", "in_progress", "resolved", "closed", "blocked"]}
|
by_type = {t: query.filter(Task.task_type == t).count()
|
||||||
by_type = {t: query.filter(models.Issue.issue_type == t).count()
|
for t in ["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"]}
|
||||||
for t in ["task", "story", "test", "resolution"]}
|
by_priority = {p.value: query.filter(Task.priority == p).count() for p in TaskPriority}
|
||||||
by_priority = {p: query.filter(models.Issue.priority == p).count()
|
recent = query.order_by(Task.created_at.desc()).limit(10).all()
|
||||||
for p in ["low", "medium", "high", "critical"]}
|
return {
|
||||||
return {"total": total, "by_status": by_status, "by_type": by_type, "by_priority": by_priority}
|
"total": total,
|
||||||
|
"total_tasks": total,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_type": by_type,
|
||||||
|
"by_priority": by_priority,
|
||||||
|
"recent_tasks": [schemas.TaskResponse.model_validate(t) for t in recent],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============ Tasks ============
|
# ============ Milestone-scoped Tasks ============
|
||||||
|
|
||||||
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
|
@router.get("/tasks/{project_code}/{milestone_id}", tags=["Tasks"])
|
||||||
def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
|
def list_milestone_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()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
@@ -533,6 +399,8 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
|
|||||||
"status": t.status.value if hasattr(t.status, "value") else t.status,
|
"status": t.status.value if hasattr(t.status, "value") else t.status,
|
||||||
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
|
"priority": t.priority.value if hasattr(t.priority, "value") else t.priority,
|
||||||
"task_code": t.task_code,
|
"task_code": t.task_code,
|
||||||
|
"task_type": t.task_type,
|
||||||
|
"task_subtype": t.task_subtype,
|
||||||
"estimated_effort": t.estimated_effort,
|
"estimated_effort": t.estimated_effort,
|
||||||
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
|
"estimated_working_time": str(t.estimated_working_time) if t.estimated_working_time else None,
|
||||||
"started_on": t.started_on,
|
"started_on": t.started_on,
|
||||||
@@ -545,9 +413,7 @@ def list_tasks(project_code: str, milestone_id: int, db: Session = Depends(get_d
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["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)):
|
def create_milestone_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()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
@@ -559,7 +425,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
|
|||||||
if ms.status and hasattr(ms.status, "value") and ms.status.value == "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")
|
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}"
|
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()
|
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
|
next_num = (max_task.id + 1) if max_task else 1
|
||||||
@@ -577,6 +442,8 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
|
|||||||
description=task_data.get("description"),
|
description=task_data.get("description"),
|
||||||
status=TaskStatus.OPEN,
|
status=TaskStatus.OPEN,
|
||||||
priority=TaskPriority.MEDIUM,
|
priority=TaskPriority.MEDIUM,
|
||||||
|
task_type=task_data.get("task_type", "task"),
|
||||||
|
task_subtype=task_data.get("task_subtype"),
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
milestone_id=milestone_id,
|
milestone_id=milestone_id,
|
||||||
reporter_id=current_user.id,
|
reporter_id=current_user.id,
|
||||||
@@ -600,78 +467,6 @@ def create_task(project_code: str, milestone_id: int, task_data: dict, db: Sessi
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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 ============
|
# ============ Supports ============
|
||||||
|
|
||||||
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
|
||||||
@@ -709,7 +504,6 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
|
|||||||
if ms.status and hasattr(ms.status, "value") and ms.status.value == "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")
|
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}"
|
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()
|
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
|
next_num = (max_support.id + 1) if max_support else 1
|
||||||
@@ -758,8 +552,6 @@ def list_meetings(project_code: str, milestone_id: int, db: Session = Depends(ge
|
|||||||
|
|
||||||
@router.post("/meetings/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["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)):
|
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()
|
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
@@ -771,7 +563,6 @@ def create_meeting(project_code: str, milestone_id: int, meeting_data: dict, db:
|
|||||||
if ms.status and hasattr(ms.status, "value") and ms.status.value == "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")
|
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}"
|
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()
|
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
|
next_num = (max_meeting.id + 1) if max_meeting else 1
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.monitor import (
|
|||||||
ServerHandshakeNonce,
|
ServerHandshakeNonce,
|
||||||
)
|
)
|
||||||
from app.services.monitoring import (
|
from app.services.monitoring import (
|
||||||
get_issue_stats_cached,
|
get_task_stats_cached,
|
||||||
get_provider_usage_view,
|
get_provider_usage_view,
|
||||||
get_server_states_view,
|
get_server_states_view,
|
||||||
test_provider_connection,
|
test_provider_connection,
|
||||||
@@ -66,7 +66,7 @@ def monitor_public_key():
|
|||||||
@router.get('/public/overview')
|
@router.get('/public/overview')
|
||||||
def public_overview(db: Session = Depends(get_db)):
|
def public_overview(db: Session = Depends(get_db)):
|
||||||
return {
|
return {
|
||||||
'issues': get_issue_stats_cached(db, ttl_seconds=1800),
|
'tasks': get_task_stats_cached(db, ttl_seconds=1800),
|
||||||
'providers': get_provider_usage_view(db),
|
'providers': get_provider_usage_view(db),
|
||||||
'servers': get_server_states_view(db, offline_after_minutes=7),
|
'servers': get_server_states_view(db, offline_after_minutes=7),
|
||||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -231,14 +231,14 @@ def delete_project(
|
|||||||
|
|
||||||
project_code = project.project_code
|
project_code = project.project_code
|
||||||
|
|
||||||
# Delete milestones and their issues
|
# Delete milestones and their tasks
|
||||||
from app.models.milestone import Milestone
|
from app.models.milestone import Milestone
|
||||||
|
from app.models.task import Task
|
||||||
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
|
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
|
||||||
for ms in milestones:
|
for ms in milestones:
|
||||||
# Delete issues under milestone
|
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
|
||||||
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
|
for task in tasks:
|
||||||
for issue in issues:
|
db.delete(task)
|
||||||
db.delete(issue)
|
|
||||||
db.delete(ms)
|
db.delete(ms)
|
||||||
|
|
||||||
# Delete project members
|
# Delete project members
|
||||||
@@ -363,13 +363,14 @@ from sqlalchemy import func as sqlfunc
|
|||||||
|
|
||||||
@router.get("/{project_id}/worklogs/summary")
|
@router.get("/{project_id}/worklogs/summary")
|
||||||
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
|
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
|
||||||
|
from app.models.task import Task as TaskModel
|
||||||
results = db.query(
|
results = db.query(
|
||||||
models.User.id, models.User.username,
|
models.User.id, models.User.username,
|
||||||
sqlfunc.sum(WorkLog.hours).label("total_hours"),
|
sqlfunc.sum(WorkLog.hours).label("total_hours"),
|
||||||
sqlfunc.count(WorkLog.id).label("log_count")
|
sqlfunc.count(WorkLog.id).label("log_count")
|
||||||
).join(WorkLog, WorkLog.user_id == models.User.id)\
|
).join(WorkLog, WorkLog.user_id == models.User.id)\
|
||||||
.join(models.Issue, WorkLog.issue_id == models.Issue.id)\
|
.join(TaskModel, WorkLog.task_id == TaskModel.id)\
|
||||||
.filter(models.Issue.project_id == project_id)\
|
.filter(TaskModel.project_id == project_id)\
|
||||||
.group_by(models.User.id, models.User.username).all()
|
.group_by(models.User.id, models.User.username).all()
|
||||||
total = sum(r.total_hours for r in results)
|
total = sum(r.total_hours for r in results)
|
||||||
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]
|
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]
|
||||||
|
|||||||
347
app/api/routers/tasks.py
Normal file
347
app/api/routers/tasks.py
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
|
||||||
|
import math
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import get_db
|
||||||
|
from app.models import models
|
||||||
|
from app.models.task import Task, TaskStatus, TaskPriority
|
||||||
|
from app.models.milestone import Milestone
|
||||||
|
from app.schemas import schemas
|
||||||
|
from app.services.webhook import fire_webhooks_sync
|
||||||
|
from app.models.notification import Notification as NotificationModel
|
||||||
|
from app.api.deps import get_current_user_or_apikey
|
||||||
|
from app.api.rbac import check_project_role
|
||||||
|
from app.services.activity import log_activity
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Tasks"])
|
||||||
|
|
||||||
|
# ---- Type / Subtype validation ----
|
||||||
|
TASK_SUBTYPE_MAP = {
|
||||||
|
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
|
||||||
|
'maintenance': {'deploy', 'release'},
|
||||||
|
'review': {'code_review', 'decision_review', 'function_review'},
|
||||||
|
'story': {'feature', 'improvement', 'refactor'},
|
||||||
|
'test': {'regression', 'security', 'smoke', 'stress'},
|
||||||
|
'research': set(),
|
||||||
|
'task': {'defect'},
|
||||||
|
'resolution': set(),
|
||||||
|
}
|
||||||
|
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None):
|
||||||
|
if task_type is None:
|
||||||
|
return
|
||||||
|
if task_type not in ALLOWED_TASK_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Invalid task_type: {task_type}')
|
||||||
|
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
|
||||||
|
if task_subtype and task_subtype not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
|
||||||
|
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
|
||||||
|
entity_type=entity_type, entity_id=entity_id)
|
||||||
|
db.add(n)
|
||||||
|
db.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CRUD ----
|
||||||
|
|
||||||
|
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
_validate_task_type_subtype(task_in.task_type, task_in.task_subtype)
|
||||||
|
|
||||||
|
data = task_in.model_dump(exclude_unset=True)
|
||||||
|
data["reporter_id"] = data.get("reporter_id") or current_user.id
|
||||||
|
data["created_by_id"] = current_user.id
|
||||||
|
|
||||||
|
if not data.get("project_id"):
|
||||||
|
raise HTTPException(status_code=400, detail="project_id is required")
|
||||||
|
if not data.get("milestone_id"):
|
||||||
|
raise HTTPException(status_code=400, detail="milestone_id is required")
|
||||||
|
|
||||||
|
check_project_role(db, current_user.id, data["project_id"], min_role="dev")
|
||||||
|
|
||||||
|
milestone = db.query(Milestone).filter(
|
||||||
|
Milestone.id == data["milestone_id"],
|
||||||
|
Milestone.project_id == data["project_id"],
|
||||||
|
).first()
|
||||||
|
if not milestone:
|
||||||
|
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||||
|
|
||||||
|
est_time = None
|
||||||
|
if data.get("estimated_working_time"):
|
||||||
|
try:
|
||||||
|
est_time = datetime.strptime(data["estimated_working_time"], "%H:%M").time()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data["estimated_working_time"] = est_time
|
||||||
|
|
||||||
|
milestone_code = milestone.milestone_code or f"m{milestone.id}"
|
||||||
|
max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first()
|
||||||
|
next_num = (max_task.id + 1) if max_task else 1
|
||||||
|
data["task_code"] = f"{milestone_code}:T{next_num:05x}"
|
||||||
|
|
||||||
|
db_task = Task(**data)
|
||||||
|
db.add(db_task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_task)
|
||||||
|
|
||||||
|
event = "resolution.created" if db_task.task_type == "resolution" else "task.created"
|
||||||
|
bg.add_task(
|
||||||
|
fire_webhooks_sync,
|
||||||
|
event,
|
||||||
|
{"task_id": db_task.id, "title": db_task.title, "type": db_task.task_type, "status": db_task.status.value},
|
||||||
|
db_task.project_id,
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
|
||||||
|
return db_task
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
def list_tasks(
|
||||||
|
project_id: int = None, task_status: str = None, task_type: str = None, task_subtype: str = None,
|
||||||
|
assignee_id: int = None, tag: str = None,
|
||||||
|
sort_by: str = "created_at", sort_order: str = "desc",
|
||||||
|
page: int = 1, page_size: int = 50,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
query = db.query(Task)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Task.project_id == project_id)
|
||||||
|
if task_status:
|
||||||
|
query = query.filter(Task.status == task_status)
|
||||||
|
if task_type:
|
||||||
|
query = query.filter(Task.task_type == task_type)
|
||||||
|
if task_subtype:
|
||||||
|
query = query.filter(Task.task_subtype == task_subtype)
|
||||||
|
if assignee_id:
|
||||||
|
query = query.filter(Task.assignee_id == assignee_id)
|
||||||
|
if tag:
|
||||||
|
query = query.filter(Task.tags.contains(tag))
|
||||||
|
|
||||||
|
sort_fields = {
|
||||||
|
"created_at": Task.created_at, "updated_at": Task.updated_at,
|
||||||
|
"priority": Task.priority, "title": Task.title,
|
||||||
|
}
|
||||||
|
sort_col = sort_fields.get(sort_by, Task.created_at)
|
||||||
|
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = min(max(1, page_size), 200)
|
||||||
|
total_pages = math.ceil(total / page_size) if total else 1
|
||||||
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return {
|
||||||
|
"items": [schemas.TaskResponse.model_validate(i) for i in items],
|
||||||
|
"total": total,
|
||||||
|
"total_tasks": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
||||||
|
def get_task(task_id: int, db: Session = Depends(get_db)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
|
||||||
|
def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
check_project_role(db, current_user.id, task.project_id, min_role="dev")
|
||||||
|
|
||||||
|
update_data = task_update.model_dump(exclude_unset=True)
|
||||||
|
if "status" in update_data:
|
||||||
|
new_status = update_data["status"]
|
||||||
|
if new_status == "progressing" and not task.started_on:
|
||||||
|
task.started_on = datetime.utcnow()
|
||||||
|
if new_status == "closed" and not task.finished_on:
|
||||||
|
task.finished_on = datetime.utcnow()
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(task, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_task(task_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
check_project_role(db, current_user.id, task.project_id, min_role="mgr")
|
||||||
|
log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Transition ----
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
|
||||||
|
def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
||||||
|
valid_statuses = [s.value for s in TaskStatus]
|
||||||
|
if new_status not in valid_statuses:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
|
if new_status == "progressing" and not task.started_on:
|
||||||
|
task.started_on = datetime.utcnow()
|
||||||
|
if new_status == "closed" and not task.finished_on:
|
||||||
|
task.finished_on = datetime.utcnow()
|
||||||
|
task.status = new_status
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
event = "task.closed" if new_status == "closed" else "task.updated"
|
||||||
|
bg.add_task(fire_webhooks_sync, event,
|
||||||
|
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
|
||||||
|
task.project_id, db)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Assignment ----
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/assign")
|
||||||
|
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
task.assignee_id = assignee_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
_notify_user(db, assignee_id, "task.assigned",
|
||||||
|
f"Task #{task.id} assigned to you",
|
||||||
|
f"'{task.title}' has been assigned to you.", "task", task.id)
|
||||||
|
return {"task_id": task.id, "assignee_id": assignee_id, "title": task.title}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tags ----
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/tags")
|
||||||
|
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
|
current.add(tag.strip())
|
||||||
|
current.discard("")
|
||||||
|
task.tags = ",".join(sorted(current))
|
||||||
|
db.commit()
|
||||||
|
return {"task_id": task_id, "tags": list(current)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}/tags")
|
||||||
|
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
current = set(task.tags.split(",")) if task.tags else set()
|
||||||
|
current.discard(tag.strip())
|
||||||
|
current.discard("")
|
||||||
|
task.tags = ",".join(sorted(current)) if current else None
|
||||||
|
db.commit()
|
||||||
|
return {"task_id": task_id, "tags": list(current)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
|
||||||
|
query = db.query(Task.tags).filter(Task.tags != None)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Task.project_id == project_id)
|
||||||
|
all_tags = set()
|
||||||
|
for (tags,) in query.all():
|
||||||
|
for t in tags.split(","):
|
||||||
|
t = t.strip()
|
||||||
|
if t:
|
||||||
|
all_tags.add(t)
|
||||||
|
return {"tags": sorted(all_tags)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Batch ----
|
||||||
|
|
||||||
|
class BatchTransition(BaseModel):
|
||||||
|
task_ids: List[int]
|
||||||
|
new_status: str
|
||||||
|
|
||||||
|
class BatchAssign(BaseModel):
|
||||||
|
task_ids: List[int]
|
||||||
|
assignee_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/batch/transition")
|
||||||
|
def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
||||||
|
valid_statuses = [s.value for s in TaskStatus]
|
||||||
|
if data.new_status not in valid_statuses:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
updated = []
|
||||||
|
for task_id in data.task_ids:
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if task:
|
||||||
|
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
||||||
|
task.status = data.new_status
|
||||||
|
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
|
||||||
|
db.commit()
|
||||||
|
for u in updated:
|
||||||
|
event = "task.closed" if data.new_status == "closed" else "task.updated"
|
||||||
|
bg.add_task(fire_webhooks_sync, event, u, None, db)
|
||||||
|
return {"updated": len(updated), "tasks": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/batch/assign")
|
||||||
|
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||||
|
updated = []
|
||||||
|
for task_id in data.task_ids:
|
||||||
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
|
if task:
|
||||||
|
task.assignee_id = data.assignee_id
|
||||||
|
updated.append(task_id)
|
||||||
|
db.commit()
|
||||||
|
return {"updated": len(updated), "task_ids": updated, "assignee_id": data.assignee_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Search ----
|
||||||
|
|
||||||
|
@router.get("/search/tasks")
|
||||||
|
def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int = 50,
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
query = db.query(Task).filter(
|
||||||
|
(Task.title.contains(q)) | (Task.description.contains(q))
|
||||||
|
)
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Task.project_id == project_id)
|
||||||
|
total = query.count()
|
||||||
|
page = max(1, page)
|
||||||
|
page_size = min(max(1, page_size), 200)
|
||||||
|
total_pages = math.ceil(total / page_size) if total else 1
|
||||||
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return {
|
||||||
|
"items": [schemas.TaskResponse.model_validate(i) for i in items],
|
||||||
|
"total": total,
|
||||||
|
"total_tasks": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
class WorkLogResponse(BaseModel):
|
class WorkLogResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
issue_id: int
|
task_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
hours: float
|
hours: float
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ DEFAULT_PERMISSIONS = [
|
|||||||
("project.write", "Edit project", "project"),
|
("project.write", "Edit project", "project"),
|
||||||
("project.delete", "Delete project", "project"),
|
("project.delete", "Delete project", "project"),
|
||||||
("project.manage_members", "Manage project members", "project"),
|
("project.manage_members", "Manage project members", "project"),
|
||||||
# Issue/Milestone permissions
|
# Task/Milestone permissions
|
||||||
("issue.create", "Create issues", "issue"),
|
("task.create", "Create tasks", "task"),
|
||||||
("issue.read", "View issues", "issue"),
|
("task.read", "View tasks", "task"),
|
||||||
("issue.write", "Edit issues", "issue"),
|
("task.write", "Edit tasks", "task"),
|
||||||
("issue.delete", "Delete issues", "issue"),
|
("task.delete", "Delete tasks", "task"),
|
||||||
("milestone.create", "Create milestones", "milestone"),
|
("milestone.create", "Create milestones", "milestone"),
|
||||||
("milestone.read", "View milestones", "milestone"),
|
("milestone.read", "View milestones", "milestone"),
|
||||||
("milestone.write", "Edit milestones", "milestone"),
|
("milestone.write", "Edit milestones", "milestone"),
|
||||||
|
|||||||
122
app/main.py
122
app/main.py
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="HarborForge API",
|
title="HarborForge API",
|
||||||
description="Agent/人类协同任务管理平台 API",
|
description="Agent/人类协同任务管理平台 API",
|
||||||
version="0.2.0"
|
version="0.3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
@@ -24,11 +24,11 @@ def health_check():
|
|||||||
|
|
||||||
@app.get("/version", tags=["System"])
|
@app.get("/version", tags=["System"])
|
||||||
def version():
|
def version():
|
||||||
return {"name": "HarborForge", "version": "0.2.0", "description": "Agent/人类协同任务管理平台"}
|
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from app.api.routers.auth import router as auth_router
|
from app.api.routers.auth import router as auth_router
|
||||||
from app.api.routers.issues import router as issues_router
|
from app.api.routers.tasks import router as tasks_router
|
||||||
from app.api.routers.projects import router as projects_router
|
from app.api.routers.projects import router as projects_router
|
||||||
from app.api.routers.users import router as users_router
|
from app.api.routers.users import router as users_router
|
||||||
from app.api.routers.comments import router as comments_router
|
from app.api.routers.comments import router as comments_router
|
||||||
@@ -39,7 +39,7 @@ from app.api.routers.milestones import router as milestones_router
|
|||||||
from app.api.routers.roles import router as roles_router
|
from app.api.routers.roles import router as roles_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(issues_router)
|
app.include_router(tasks_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(comments_router)
|
app.include_router(comments_router)
|
||||||
@@ -54,33 +54,113 @@ app.include_router(roles_router)
|
|||||||
def _migrate_schema():
|
def _migrate_schema():
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.core.config import SessionLocal
|
from app.core.config import SessionLocal
|
||||||
|
|
||||||
|
def _has_table(db, table_name: str) -> bool:
|
||||||
|
return db.execute(text("SHOW TABLES LIKE :table_name"), {"table_name": table_name}).fetchone() is not None
|
||||||
|
|
||||||
|
def _has_column(db, table_name: str, column_name: str) -> bool:
|
||||||
|
return db.execute(
|
||||||
|
text(f"SHOW COLUMNS FROM {table_name} LIKE :column_name"),
|
||||||
|
{"column_name": column_name},
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
def _drop_fk_constraints(db, table_name: str, referenced_table: str):
|
||||||
|
rows = db.execute(text(
|
||||||
|
"""
|
||||||
|
SELECT CONSTRAINT_NAME
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table_name
|
||||||
|
AND REFERENCED_TABLE_NAME = :referenced_table
|
||||||
|
AND CONSTRAINT_NAME <> 'PRIMARY'
|
||||||
|
"""
|
||||||
|
), {"table_name": table_name, "referenced_table": referenced_table}).fetchall()
|
||||||
|
for (constraint_name,) in rows:
|
||||||
|
db.execute(text(f"ALTER TABLE {table_name} DROP FOREIGN KEY `{constraint_name}`"))
|
||||||
|
|
||||||
|
def _ensure_fk(db, table_name: str, column_name: str, referenced_table: str, referenced_column: str, constraint_name: str):
|
||||||
|
exists = db.execute(text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = :table_name
|
||||||
|
AND COLUMN_NAME = :column_name
|
||||||
|
AND REFERENCED_TABLE_NAME = :referenced_table
|
||||||
|
AND REFERENCED_COLUMN_NAME = :referenced_column
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
), {
|
||||||
|
"table_name": table_name,
|
||||||
|
"column_name": column_name,
|
||||||
|
"referenced_table": referenced_table,
|
||||||
|
"referenced_column": referenced_column,
|
||||||
|
}).fetchone()
|
||||||
|
if not exists:
|
||||||
|
db.execute(text(
|
||||||
|
f"ALTER TABLE {table_name} ADD CONSTRAINT `{constraint_name}` FOREIGN KEY ({column_name}) REFERENCES {referenced_table}({referenced_column})"
|
||||||
|
))
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# issues.issue_subtype
|
|
||||||
result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone()
|
|
||||||
if not result:
|
|
||||||
db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL"))
|
|
||||||
# issues.issue_type enum -> varchar
|
|
||||||
result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone()
|
|
||||||
if result and 'enum' in result[1].lower():
|
|
||||||
db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'"))
|
|
||||||
# projects.project_code
|
# projects.project_code
|
||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone()
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'"))
|
||||||
if not result:
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
||||||
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
||||||
|
|
||||||
# projects.owner_name
|
# projects.owner_name
|
||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone()
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'"))
|
||||||
if not result:
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
|
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
|
||||||
|
|
||||||
# projects.sub_projects / related_projects
|
# projects.sub_projects / related_projects
|
||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone()
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'"))
|
||||||
if not result:
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
|
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
|
||||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone()
|
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'"))
|
||||||
if not result:
|
if not result.fetchone():
|
||||||
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
|
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
|
||||||
|
|
||||||
|
# tasks extra fields
|
||||||
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_type'"))
|
||||||
|
if not result.fetchone():
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN task_type VARCHAR(32) DEFAULT 'task'"))
|
||||||
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'task_subtype'"))
|
||||||
|
if not result.fetchone():
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN task_subtype VARCHAR(64) NULL"))
|
||||||
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'tags'"))
|
||||||
|
if not result.fetchone():
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN tags VARCHAR(500) NULL"))
|
||||||
|
result = db.execute(text("SHOW COLUMNS FROM tasks LIKE 'resolution_summary'"))
|
||||||
|
if not result.fetchone():
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN resolution_summary TEXT NULL"))
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN positions TEXT NULL"))
|
||||||
|
db.execute(text("ALTER TABLE tasks ADD COLUMN pending_matters TEXT NULL"))
|
||||||
|
|
||||||
|
# comments: issue_id -> task_id
|
||||||
|
if _has_table(db, "comments"):
|
||||||
|
_drop_fk_constraints(db, "comments", "issues")
|
||||||
|
if _has_column(db, "comments", "issue_id") and not _has_column(db, "comments", "task_id"):
|
||||||
|
db.execute(text("ALTER TABLE comments CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
|
||||||
|
if _has_column(db, "comments", "task_id"):
|
||||||
|
_ensure_fk(db, "comments", "task_id", "tasks", "id", "fk_comments_task_id")
|
||||||
|
|
||||||
|
# work_logs: issue_id -> task_id
|
||||||
|
if _has_table(db, "work_logs"):
|
||||||
|
_drop_fk_constraints(db, "work_logs", "issues")
|
||||||
|
if _has_column(db, "work_logs", "issue_id") and not _has_column(db, "work_logs", "task_id"):
|
||||||
|
db.execute(text("ALTER TABLE work_logs CHANGE COLUMN issue_id task_id INTEGER NOT NULL"))
|
||||||
|
if _has_column(db, "work_logs", "task_id"):
|
||||||
|
_ensure_fk(db, "work_logs", "task_id", "tasks", "id", "fk_work_logs_task_id")
|
||||||
|
|
||||||
|
# Drop issues table if it exists (no longer used anywhere)
|
||||||
|
if _has_table(db, "issues"):
|
||||||
|
db.execute(text("DROP TABLE issues"))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
print(f"Migration warning: {e}")
|
print(f"Migration warning: {e}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -89,7 +169,7 @@ def _migrate_schema():
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
from app.core.config import Base, engine, SessionLocal
|
from app.core.config import Base, engine, SessionLocal
|
||||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ class ActivityLog(Base):
|
|||||||
__tablename__ = "activity_logs"
|
__tablename__ = "activity_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
action = Column(String(50), nullable=False) # e.g. "issue.created", "comment.added"
|
action = Column(String(50), nullable=False) # e.g. "task.created", "comment.added"
|
||||||
entity_type = Column(String(50), nullable=False) # "issue", "project", "comment"
|
entity_type = Column(String(50), nullable=False) # "task", "project", "comment"
|
||||||
entity_id = Column(Integer, nullable=False)
|
entity_id = Column(Integer, nullable=False)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
details = Column(Text, nullable=True) # JSON string
|
details = Column(Text, nullable=True) # JSON string
|
||||||
|
|||||||
@@ -6,96 +6,42 @@ from app.models.role_permission import Role
|
|||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
|
||||||
class IssueType(str, enum.Enum):
|
class TaskType(str, enum.Enum):
|
||||||
MEETING = "meeting"
|
"""Task type enum — 'issue' is a subtype of task, not the other way around."""
|
||||||
SUPPORT = "support"
|
|
||||||
ISSUE = "issue"
|
ISSUE = "issue"
|
||||||
MAINTENANCE = "maintenance"
|
MAINTENANCE = "maintenance"
|
||||||
RESEARCH = "research"
|
RESEARCH = "research"
|
||||||
REVIEW = "review"
|
REVIEW = "review"
|
||||||
STORY = "story"
|
STORY = "story"
|
||||||
TEST = "test"
|
TEST = "test"
|
||||||
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交
|
RESOLUTION = "resolution"
|
||||||
TASK = "task" # legacy generic type
|
TASK = "task"
|
||||||
|
|
||||||
|
|
||||||
class IssueStatus(str, enum.Enum):
|
class TaskStatus(str, enum.Enum):
|
||||||
OPEN = "open"
|
OPEN = "open"
|
||||||
IN_PROGRESS = "in_progress"
|
PENDING = "pending"
|
||||||
RESOLVED = "resolved"
|
PROGRESSING = "progressing"
|
||||||
CLOSED = "closed"
|
CLOSED = "closed"
|
||||||
BLOCKED = "blocked"
|
|
||||||
|
|
||||||
|
|
||||||
class IssuePriority(str, enum.Enum):
|
class TaskPriority(str, enum.Enum):
|
||||||
LOW = "low"
|
LOW = "low"
|
||||||
MEDIUM = "medium"
|
MEDIUM = "medium"
|
||||||
HIGH = "high"
|
HIGH = "high"
|
||||||
CRITICAL = "critical"
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
class Issue(Base):
|
|
||||||
__tablename__ = "issues"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
issue_type = Column(String(32), default=IssueType.ISSUE.value)
|
|
||||||
issue_subtype = Column(String(64), nullable=True)
|
|
||||||
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
|
|
||||||
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
|
||||||
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
||||||
|
|
||||||
# Resolution specific fields (for RESOLUTION type)
|
|
||||||
resolution_summary = Column(Text, nullable=True) # 僵局摘要
|
|
||||||
positions = Column(Text, nullable=True) # 各方立场 (JSON)
|
|
||||||
pending_matters = Column(Text, nullable=True) # 待决事项
|
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# Tags (comma-separated for simplicity)
|
|
||||||
tags = Column(String(500), nullable=True)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
reporter = relationship("User", foreign_keys=[reporter_id], back_populates="reported_issues")
|
|
||||||
assignee = relationship("User", foreign_keys=[assignee_id], back_populates="assigned_issues")
|
|
||||||
comments = relationship("Comment", back_populates="issue", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False)
|
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||||
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
author_id = Column(Integer, ForeignKey("users.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())
|
||||||
|
|
||||||
issue = relationship("Issue", back_populates="comments")
|
|
||||||
author = relationship("User", back_populates="comments")
|
author = relationship("User", back_populates="comments")
|
||||||
|
|
||||||
|
|
||||||
@@ -114,7 +60,6 @@ class Project(Base):
|
|||||||
|
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
issues = relationship("Issue", back_populates="project", cascade="all, delete-orphan")
|
|
||||||
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||||
owner = relationship("User", back_populates="owned_projects")
|
owner = relationship("User", back_populates="owned_projects")
|
||||||
|
|
||||||
@@ -125,15 +70,13 @@ class User(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
email = Column(String(100), unique=True, nullable=False)
|
email = Column(String(100), unique=True, nullable=False)
|
||||||
hashed_password = Column(String(255), nullable=True) # Nullable for OAuth users
|
hashed_password = Column(String(255), nullable=True)
|
||||||
full_name = Column(String(100), nullable=True)
|
full_name = Column(String(100), nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
owned_projects = relationship("Project", back_populates="owner")
|
owned_projects = relationship("Project", back_populates="owner")
|
||||||
reported_issues = relationship("Issue", foreign_keys=[Issue.reporter_id], back_populates="reporter")
|
|
||||||
assigned_issues = relationship("Issue", foreign_keys=[Issue.assignee_id], back_populates="assignee")
|
|
||||||
comments = relationship("Comment", back_populates="author")
|
comments = relationship("Comment", back_populates="author")
|
||||||
project_memberships = relationship("ProjectMember", back_populates="user")
|
project_memberships = relationship("ProjectMember", back_populates="user")
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ class Notification(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due
|
type = Column(String(50), nullable=False) # task.assigned, task.mentioned, comment.added, milestone.due
|
||||||
title = Column(String(255), nullable=False)
|
title = Column(String(255), nullable=False)
|
||||||
message = Column(Text, nullable=True)
|
message = Column(Text, nullable=True)
|
||||||
entity_type = Column(String(50), nullable=True) # issue, comment, milestone
|
entity_type = Column(String(50), nullable=True) # task, comment, milestone
|
||||||
entity_id = Column(Integer, nullable=True)
|
entity_id = Column(Integer, nullable=True)
|
||||||
is_read = Column(Boolean, default=False)
|
is_read = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -26,18 +26,35 @@ class Task(Base):
|
|||||||
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
priority = Column(Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||||
task_code = Column(String(64), nullable=True, unique=True, index=True)
|
task_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||||
|
|
||||||
|
# Task type/subtype (replaces old issue_type/issue_subtype)
|
||||||
|
task_type = Column(String(32), default="task")
|
||||||
|
task_subtype = Column(String(64), nullable=True)
|
||||||
|
|
||||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||||
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
|
milestone_id = Column(Integer, ForeignKey("milestones.id"), nullable=False)
|
||||||
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# Tags (comma-separated)
|
||||||
|
tags = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
depend_on = Column(Text, nullable=True)
|
depend_on = Column(Text, nullable=True)
|
||||||
|
related_tasks = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Effort tracking
|
||||||
estimated_effort = Column(Integer, nullable=True)
|
estimated_effort = Column(Integer, nullable=True)
|
||||||
estimated_working_time = Column(Time, nullable=True)
|
estimated_working_time = Column(Time, nullable=True)
|
||||||
started_on = Column(DateTime(timezone=True), nullable=True)
|
started_on = Column(DateTime(timezone=True), nullable=True)
|
||||||
finished_on = Column(DateTime(timezone=True), nullable=True)
|
finished_on = Column(DateTime(timezone=True), nullable=True)
|
||||||
related_tasks = Column(Text, nullable=True)
|
|
||||||
|
# Resolution specific fields (for task_type="resolution")
|
||||||
|
resolution_summary = Column(Text, nullable=True)
|
||||||
|
positions = Column(Text, nullable=True)
|
||||||
|
pending_matters = Column(Text, nullable=True)
|
||||||
|
|
||||||
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())
|
||||||
|
|
||||||
|
comments = relationship("Comment", foreign_keys="Comment.task_id", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import enum
|
|||||||
|
|
||||||
|
|
||||||
class WebhookEvent(str, enum.Enum):
|
class WebhookEvent(str, enum.Enum):
|
||||||
ISSUE_CREATED = "issue.created"
|
TASK_CREATED = "task.created"
|
||||||
ISSUE_UPDATED = "issue.updated"
|
TASK_UPDATED = "task.updated"
|
||||||
ISSUE_CLOSED = "issue.closed"
|
TASK_CLOSED = "task.closed"
|
||||||
ISSUE_DELETED = "issue.deleted"
|
TASK_DELETED = "task.deleted"
|
||||||
COMMENT_CREATED = "comment.created"
|
COMMENT_CREATED = "comment.created"
|
||||||
RESOLUTION_CREATED = "resolution.created"
|
RESOLUTION_CREATED = "resolution.created"
|
||||||
MEMBER_ADDED = "member.added"
|
MEMBER_ADDED = "member.added"
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ class WorkLog(Base):
|
|||||||
__tablename__ = "work_logs"
|
__tablename__ = "work_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
issue_id = Column(Integer, ForeignKey("issues.id"), nullable=False)
|
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
hours = Column(Float, nullable=False) # Hours spent
|
hours = Column(Float, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
logged_date = Column(DateTime(timezone=True), nullable=False) # When the work was done
|
logged_date = Column(DateTime(timezone=True), nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class IssueTypeEnum(str, Enum):
|
class TaskTypeEnum(str, Enum):
|
||||||
MEETING = "meeting"
|
|
||||||
SUPPORT = "support"
|
|
||||||
ISSUE = "issue"
|
ISSUE = "issue"
|
||||||
MAINTENANCE = "maintenance"
|
MAINTENANCE = "maintenance"
|
||||||
RESEARCH = "research"
|
RESEARCH = "research"
|
||||||
@@ -14,40 +12,39 @@ class IssueTypeEnum(str, Enum):
|
|||||||
STORY = "story"
|
STORY = "story"
|
||||||
TEST = "test"
|
TEST = "test"
|
||||||
RESOLUTION = "resolution"
|
RESOLUTION = "resolution"
|
||||||
TASK = "task" # legacy
|
TASK = "task"
|
||||||
|
|
||||||
|
|
||||||
class IssueStatusEnum(str, Enum):
|
class TaskStatusEnum(str, Enum):
|
||||||
OPEN = "open"
|
OPEN = "open"
|
||||||
IN_PROGRESS = "in_progress"
|
PENDING = "pending"
|
||||||
RESOLVED = "resolved"
|
PROGRESSING = "progressing"
|
||||||
CLOSED = "closed"
|
CLOSED = "closed"
|
||||||
BLOCKED = "blocked"
|
|
||||||
|
|
||||||
|
|
||||||
class IssuePriorityEnum(str, Enum):
|
class TaskPriorityEnum(str, Enum):
|
||||||
LOW = "low"
|
LOW = "low"
|
||||||
MEDIUM = "medium"
|
MEDIUM = "medium"
|
||||||
HIGH = "high"
|
HIGH = "high"
|
||||||
CRITICAL = "critical"
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
# Issue schemas
|
# Task schemas
|
||||||
class IssueBase(BaseModel):
|
class TaskBase(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
|
task_type: TaskTypeEnum = TaskTypeEnum.TASK
|
||||||
issue_subtype: Optional[str] = None
|
task_subtype: Optional[str] = None
|
||||||
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
|
priority: TaskPriorityEnum = TaskPriorityEnum.MEDIUM
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
depends_on_id: Optional[int] = None
|
estimated_effort: Optional[int] = None
|
||||||
due_date: Optional[datetime] = None
|
estimated_working_time: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(TaskBase):
|
||||||
|
project_id: Optional[int] = None
|
||||||
milestone_id: Optional[int] = None
|
milestone_id: Optional[int] = None
|
||||||
|
reporter_id: Optional[int] = None
|
||||||
|
|
||||||
class IssueCreate(IssueBase):
|
|
||||||
project_id: int
|
|
||||||
reporter_id: int
|
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
# Resolution specific
|
# Resolution specific
|
||||||
resolution_summary: Optional[str] = None
|
resolution_summary: Optional[str] = None
|
||||||
@@ -55,37 +52,35 @@ class IssueCreate(IssueBase):
|
|||||||
pending_matters: Optional[str] = None
|
pending_matters: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class IssueUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
issue_type: Optional[IssueTypeEnum] = None
|
task_type: Optional[TaskTypeEnum] = None
|
||||||
issue_subtype: Optional[str] = None
|
task_subtype: Optional[str] = None
|
||||||
status: Optional[IssueStatusEnum] = None
|
status: Optional[TaskStatusEnum] = None
|
||||||
priority: Optional[IssuePriorityEnum] = None
|
priority: Optional[TaskPriorityEnum] = None
|
||||||
assignee_id: Optional[int] = None
|
assignee_id: Optional[int] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
depends_on_id: Optional[int] = None
|
estimated_effort: 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
|
||||||
pending_matters: Optional[str] = None
|
pending_matters: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class IssueResponse(IssueBase):
|
class TaskResponse(TaskBase):
|
||||||
id: int
|
id: int
|
||||||
status: IssueStatusEnum
|
status: TaskStatusEnum
|
||||||
|
task_code: Optional[str] = None
|
||||||
project_id: int
|
project_id: int
|
||||||
|
milestone_id: int
|
||||||
reporter_id: int
|
reporter_id: int
|
||||||
assignee_id: Optional[int]
|
assignee_id: Optional[int] = None
|
||||||
resolution_summary: Optional[str]
|
resolution_summary: Optional[str] = None
|
||||||
positions: Optional[str]
|
positions: Optional[str] = None
|
||||||
pending_matters: Optional[str]
|
pending_matters: Optional[str] = None
|
||||||
due_date: Optional[datetime] = None
|
|
||||||
milestone_id: Optional[int] = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -97,7 +92,7 @@ class CommentBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CommentCreate(CommentBase):
|
class CommentCreate(CommentBase):
|
||||||
issue_id: int
|
task_id: int
|
||||||
author_id: int
|
author_id: int
|
||||||
|
|
||||||
|
|
||||||
@@ -107,10 +102,10 @@ class CommentUpdate(BaseModel):
|
|||||||
|
|
||||||
class CommentResponse(CommentBase):
|
class CommentResponse(CommentBase):
|
||||||
id: int
|
id: int
|
||||||
issue_id: int
|
task_id: int
|
||||||
author_id: int
|
author_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -147,15 +142,6 @@ class ProjectResponse(BaseModel):
|
|||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class _ProjectResponse_Inactive(ProjectBase):
|
|
||||||
id: int
|
|
||||||
owner_id: int
|
|
||||||
project_code: str | None = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
# User schemas
|
# User schemas
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
|||||||
class WebhookCreate(BaseModel):
|
class WebhookCreate(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
secret: Optional[str] = None
|
secret: Optional[str] = None
|
||||||
events: str # comma-separated: "issue.created,issue.updated"
|
events: str # comma-separated: "task.created,task.updated"
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, Dict, Tuple
|
|||||||
import requests
|
import requests
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.models import Issue
|
from app.models.task import Task, TaskStatus
|
||||||
from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
|
from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
|
||||||
|
|
||||||
_CACHE: Dict[str, Dict[str, Any]] = {}
|
_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -70,25 +70,25 @@ def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
|
def get_task_stats_cached(db: Session, ttl_seconds: int = 1800):
|
||||||
key = 'issue_stats_24h'
|
key = 'task_stats_24h'
|
||||||
now = _now()
|
now = _now()
|
||||||
hit = _CACHE.get(key)
|
hit = _CACHE.get(key)
|
||||||
if hit and (now - hit['at']).total_seconds() < ttl_seconds:
|
if hit and (now - hit['at']).total_seconds() < ttl_seconds:
|
||||||
return hit['data']
|
return hit['data']
|
||||||
|
|
||||||
since = now - timedelta(hours=24)
|
since = now - timedelta(hours=24)
|
||||||
total = db.query(Issue).count()
|
total = db.query(Task).count()
|
||||||
new_24h = db.query(Issue).filter(Issue.created_at >= since).count()
|
new_24h = db.query(Task).filter(Task.created_at >= since).count()
|
||||||
processed_24h = db.query(Issue).filter(
|
processed_24h = db.query(Task).filter(
|
||||||
Issue.updated_at != None,
|
Task.updated_at != None,
|
||||||
Issue.updated_at >= since,
|
Task.updated_at >= since,
|
||||||
Issue.status.in_(['resolved', 'closed'])
|
Task.status == TaskStatus.CLOSED,
|
||||||
).count()
|
).count()
|
||||||
data = {
|
data = {
|
||||||
'total_issues': total,
|
'total_tasks': total,
|
||||||
'new_issues_24h': new_24h,
|
'new_tasks_24h': new_24h,
|
||||||
'processed_issues_24h': processed_24h,
|
'processed_tasks_24h': processed_24h,
|
||||||
'computed_at': now.isoformat(),
|
'computed_at': now.isoformat(),
|
||||||
'cache_ttl_seconds': ttl_seconds,
|
'cache_ttl_seconds': ttl_seconds,
|
||||||
}
|
}
|
||||||
|
|||||||
233
cli.py
233
cli.py
@@ -5,27 +5,47 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
|
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
|
||||||
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "")
|
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_ICON = {
|
||||||
|
"open": "🟢",
|
||||||
|
"pending": "🟡",
|
||||||
|
"progressing": "🔵",
|
||||||
|
"closed": "⚫",
|
||||||
|
}
|
||||||
|
TYPE_ICON = {
|
||||||
|
"resolution": "⚖️",
|
||||||
|
"task": "📋",
|
||||||
|
"story": "📖",
|
||||||
|
"test": "🧪",
|
||||||
|
"issue": "📌",
|
||||||
|
"maintenance": "🛠️",
|
||||||
|
"research": "🔬",
|
||||||
|
"review": "🧐",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _request(method, path, data=None):
|
def _request(method, path, data=None):
|
||||||
url = f"{BASE_URL}{path}"
|
url = f"{BASE_URL}{path}"
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if TOKEN:
|
if TOKEN:
|
||||||
headers["Authorization"] = f"Bearer {TOKEN}"
|
headers["Authorization"] = f"Bearer {TOKEN}"
|
||||||
|
|
||||||
body = json.dumps(data).encode() if data else None
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
if resp.status == 204:
|
if resp.status == 204:
|
||||||
return None
|
return None
|
||||||
return json.loads(resp.read())
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
|
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -45,36 +65,39 @@ def cmd_login(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_issues(args):
|
def cmd_tasks(args):
|
||||||
params = []
|
params = []
|
||||||
if args.project:
|
if args.project:
|
||||||
params.append(f"project_id={args.project}")
|
params.append(f"project_id={args.project}")
|
||||||
if args.type:
|
if args.type:
|
||||||
params.append(f"issue_type={args.type}")
|
params.append(f"task_type={args.type}")
|
||||||
if args.status:
|
if args.status:
|
||||||
params.append(f"issue_status={args.status}")
|
params.append(f"task_status={args.status}")
|
||||||
qs = f"?{'&'.join(params)}" if params else ""
|
qs = f"?{'&'.join(params)}" if params else ""
|
||||||
issues = _request("GET", f"/issues{qs}")
|
result = _request("GET", f"/tasks{qs}")
|
||||||
for i in issues:
|
items = result.get("items", result if isinstance(result, list) else [])
|
||||||
status_icon = {"open": "🟢", "in_progress": "🔵", "resolved": "✅", "closed": "⚫", "blocked": "🔴"}.get(i["status"], "❓")
|
for task in items:
|
||||||
type_icon = {"resolution": "⚖️", "task": "📋", "story": "📖", "test": "🧪"}.get(i["issue_type"], "📌")
|
status_icon = STATUS_ICON.get(task["status"], "❓")
|
||||||
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}")
|
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
|
||||||
|
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_issue_create(args):
|
def cmd_task_create(args):
|
||||||
data = {
|
data = {
|
||||||
"title": args.title,
|
"title": args.title,
|
||||||
"project_id": args.project,
|
"project_id": args.project,
|
||||||
|
"milestone_id": args.milestone,
|
||||||
"reporter_id": args.reporter,
|
"reporter_id": args.reporter,
|
||||||
"issue_type": args.type,
|
"task_type": args.type,
|
||||||
"priority": args.priority or "medium",
|
"priority": args.priority or "medium",
|
||||||
}
|
}
|
||||||
if args.description:
|
if args.description:
|
||||||
data["description"] = args.description
|
data["description"] = args.description
|
||||||
if args.assignee:
|
if args.assignee:
|
||||||
data["assignee_id"] = args.assignee
|
data["assignee_id"] = args.assignee
|
||||||
|
if args.subtype:
|
||||||
|
data["task_subtype"] = args.subtype
|
||||||
|
|
||||||
# Resolution specific
|
|
||||||
if args.type == "resolution":
|
if args.type == "resolution":
|
||||||
if args.summary:
|
if args.summary:
|
||||||
data["resolution_summary"] = args.summary
|
data["resolution_summary"] = args.summary
|
||||||
@@ -83,21 +106,21 @@ def cmd_issue_create(args):
|
|||||||
if args.pending:
|
if args.pending:
|
||||||
data["pending_matters"] = args.pending
|
data["pending_matters"] = args.pending
|
||||||
|
|
||||||
result = _request("POST", "/issues", data)
|
result = _request("POST", "/tasks", data)
|
||||||
print(f"Created issue #{result['id']}: {result['title']}")
|
print(f"Created task #{result['id']}: {result['title']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_projects(args):
|
def cmd_projects(args):
|
||||||
projects = _request("GET", "/projects")
|
projects = _request("GET", "/projects")
|
||||||
for p in projects:
|
for project in projects:
|
||||||
print(f" #{p['id']} {p['name']} - {p.get('description', '')}")
|
print(f" #{project['id']} {project['name']} - {project.get('description', '')}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_users(args):
|
def cmd_users(args):
|
||||||
users = _request("GET", "/users")
|
users = _request("GET", "/users")
|
||||||
for u in users:
|
for user in users:
|
||||||
role = "👑" if u["is_admin"] else "👤"
|
role = "👑" if user["is_admin"] else "👤"
|
||||||
print(f" {role} #{u['id']} {u['username']} ({u.get('full_name', '')})")
|
print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})")
|
||||||
|
|
||||||
|
|
||||||
def cmd_version(args):
|
def cmd_version(args):
|
||||||
@@ -110,41 +133,38 @@ def cmd_health(args):
|
|||||||
print(f"Status: {result['status']}")
|
print(f"Status: {result['status']}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_search(args):
|
def cmd_search(args):
|
||||||
params = [f"q={args.query}"]
|
params = [f"q={urllib.parse.quote(args.query)}"]
|
||||||
if args.project:
|
if args.project:
|
||||||
params.append(f"project_id={args.project}")
|
params.append(f"project_id={args.project}")
|
||||||
qs = "&".join(params)
|
result = _request("GET", f"/search/tasks?{'&'.join(params)}")
|
||||||
issues = _request("GET", f"/search/issues?{qs}")
|
items = result.get("items", result if isinstance(result, list) else [])
|
||||||
if not issues:
|
if not items:
|
||||||
print(" No results found.")
|
print(" No results found.")
|
||||||
return
|
return
|
||||||
for i in issues:
|
for task in items:
|
||||||
status_icon = {"open": "\U0001f7e2", "in_progress": "\U0001f535", "resolved": "\u2705", "closed": "\u26ab", "blocked": "\U0001f534"}.get(i["status"], "\u2753")
|
status_icon = STATUS_ICON.get(task["status"], "❓")
|
||||||
type_icon = {"resolution": "\u2696\ufe0f", "task": "\U0001f4cb", "story": "\U0001f4d6", "test": "\U0001f9ea"}.get(i["issue_type"], "\U0001f4cc")
|
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
|
||||||
print(f" {status_icon} {type_icon} #{i['id']} [{i['priority']}] {i['title']}")
|
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_transition(args):
|
def cmd_transition(args):
|
||||||
result = _request("POST", f"/issues/{args.issue_id}/transition?new_status={args.status}")
|
result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}")
|
||||||
print(f"Issue #{result['id']} transitioned to: {result['status']}")
|
print(f"Task #{result['id']} transitioned to: {result['status']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_stats(args):
|
def cmd_stats(args):
|
||||||
params = f"?project_id={args.project}" if args.project else ""
|
params = f"?project_id={args.project}" if args.project else ""
|
||||||
stats = _request("GET", f"/dashboard/stats{params}")
|
stats = _request("GET", f"/dashboard/stats{params}")
|
||||||
print(f"Total: {stats['total']}")
|
print(f"Total: {stats['total_tasks']}")
|
||||||
print("By status:")
|
print("By status:")
|
||||||
for s, c in stats["by_status"].items():
|
for status_name, count in stats["by_status"].items():
|
||||||
if c > 0:
|
if count > 0:
|
||||||
print(f" {s}: {c}")
|
print(f" {status_name}: {count}")
|
||||||
print("By type:")
|
print("By type:")
|
||||||
for t, c in stats["by_type"].items():
|
for task_type, count in stats["by_type"].items():
|
||||||
if c > 0:
|
if count > 0:
|
||||||
print(f" {t}: {c}")
|
print(f" {task_type}: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_milestones(args):
|
def cmd_milestones(args):
|
||||||
@@ -153,10 +173,10 @@ def cmd_milestones(args):
|
|||||||
if not milestones:
|
if not milestones:
|
||||||
print(" No milestones found.")
|
print(" No milestones found.")
|
||||||
return
|
return
|
||||||
for m in milestones:
|
for milestone in milestones:
|
||||||
status_icon = "🟢" if m["status"] == "open" else "⚫"
|
status_icon = STATUS_ICON.get(milestone["status"], "⚪")
|
||||||
due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else ""
|
due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else ""
|
||||||
print(f" {status_icon} #{m['id']} {m['title']}{due}")
|
print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_milestone_progress(args):
|
def cmd_milestone_progress(args):
|
||||||
@@ -165,140 +185,114 @@ def cmd_milestone_progress(args):
|
|||||||
filled = int(bar_len * result["progress_pct"] / 100)
|
filled = int(bar_len * result["progress_pct"] / 100)
|
||||||
bar = "█" * filled + "░" * (bar_len - filled)
|
bar = "█" * filled + "░" * (bar_len - filled)
|
||||||
print(f" {result['title']}")
|
print(f" {result['title']}")
|
||||||
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})")
|
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})")
|
||||||
|
|
||||||
|
|
||||||
def cmd_notifications(args):
|
def cmd_notifications(args):
|
||||||
params = [f"user_id={args.user}"]
|
params = []
|
||||||
if args.unread:
|
if args.unread:
|
||||||
params.append("unread_only=true")
|
params.append("unread_only=true")
|
||||||
qs = "&".join(params)
|
qs = f"?{'&'.join(params)}" if params else ""
|
||||||
notifs = _request("GET", f"/notifications?{qs}")
|
notifications = _request("GET", f"/notifications{qs}")
|
||||||
if not notifs:
|
if not notifications:
|
||||||
print(" No notifications.")
|
print(" No notifications.")
|
||||||
return
|
return
|
||||||
for n in notifs:
|
for notification in notifications:
|
||||||
icon = "🔴" if not n["is_read"] else "⚪"
|
icon = "🔴" if not notification["is_read"] else "⚪"
|
||||||
print(f" {icon} [{n['type']}] {n['title']}")
|
print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_overdue(args):
|
def cmd_overdue(args):
|
||||||
params = f"?project_id={args.project}" if args.project else ""
|
print("Overdue tasks are not supported by the current milestone-based task schema.")
|
||||||
issues = _request("GET", f"/issues/overdue{params}")
|
|
||||||
if not issues:
|
|
||||||
print(" No overdue issues! 🎉")
|
|
||||||
return
|
|
||||||
for i in issues:
|
|
||||||
due = i.get("due_date", "?")[:10] if i.get("due_date") else "?"
|
|
||||||
print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_log_time(args):
|
def cmd_log_time(args):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'issue_id': args.issue_id,
|
"task_id": args.task_id,
|
||||||
'user_id': args.user_id,
|
"user_id": args.user_id,
|
||||||
'hours': args.hours,
|
"hours": args.hours,
|
||||||
'logged_date': datetime.utcnow().isoformat(),
|
"logged_date": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
if args.desc:
|
if args.desc:
|
||||||
data['description'] = args.desc
|
data["description"] = args.desc
|
||||||
r = api('POST', '/worklogs', json=data)
|
result = _request("POST", "/worklogs", data)
|
||||||
print(f'Logged {r["hours"]}h on issue #{r["issue_id"]} (log #{r["id"]})')
|
print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})")
|
||||||
|
|
||||||
|
|
||||||
def cmd_worklogs(args):
|
def cmd_worklogs(args):
|
||||||
logs = api('GET', f'/issues/{args.issue_id}/worklogs')
|
logs = _request("GET", f"/tasks/{args.task_id}/worklogs")
|
||||||
for l in logs:
|
for log in logs:
|
||||||
desc = f' - {l["description"]}' if l.get('description') else ''
|
desc = f" - {log['description']}" if log.get("description") else ""
|
||||||
print(f' [{l["id"]}] {l["hours"]}h by user#{l["user_id"]} on {l["logged_date"]}{desc}')
|
print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}")
|
||||||
summary = api('GET', f'/issues/{args.issue_id}/worklogs/summary')
|
summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary")
|
||||||
print(f' Total: {summary["total_hours"]}h ({summary["log_count"]} logs)')
|
print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="HarborForge CLI")
|
parser = argparse.ArgumentParser(description="HarborForge CLI")
|
||||||
sub = parser.add_subparsers(dest="command")
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
# login
|
|
||||||
p_login = sub.add_parser("login", help="Login and get token")
|
p_login = sub.add_parser("login", help="Login and get token")
|
||||||
p_login.add_argument("username")
|
p_login.add_argument("username")
|
||||||
p_login.add_argument("password")
|
p_login.add_argument("password")
|
||||||
|
|
||||||
# issues
|
p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks")
|
||||||
p_issues = sub.add_parser("issues", help="List issues")
|
p_tasks.add_argument("--project", "-p", type=int)
|
||||||
p_issues.add_argument("--project", "-p", type=int)
|
p_tasks.add_argument("--type", "-t", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"])
|
||||||
p_issues.add_argument("--type", "-t", choices=["task", "story", "test", "resolution"])
|
p_tasks.add_argument("--status", "-s", choices=["open", "pending", "progressing", "closed"])
|
||||||
p_issues.add_argument("--status", "-s")
|
|
||||||
|
|
||||||
# issue create
|
p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task")
|
||||||
p_create = sub.add_parser("create-issue", help="Create an issue")
|
|
||||||
p_create.add_argument("title")
|
p_create.add_argument("title")
|
||||||
p_create.add_argument("--project", "-p", type=int, required=True)
|
p_create.add_argument("--project", "-p", type=int, required=True)
|
||||||
|
p_create.add_argument("--milestone", "-m", type=int, required=True)
|
||||||
p_create.add_argument("--reporter", "-r", type=int, required=True)
|
p_create.add_argument("--reporter", "-r", type=int, required=True)
|
||||||
p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution"])
|
p_create.add_argument("--type", "-t", default="task", choices=["task", "story", "test", "resolution", "issue", "maintenance", "research", "review"])
|
||||||
|
p_create.add_argument("--subtype")
|
||||||
p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"])
|
p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"])
|
||||||
p_create.add_argument("--description", "-d")
|
p_create.add_argument("--description", "-d")
|
||||||
p_create.add_argument("--assignee", "-a", type=int)
|
p_create.add_argument("--assignee", "-a", type=int)
|
||||||
# Resolution fields
|
|
||||||
p_create.add_argument("--summary")
|
p_create.add_argument("--summary")
|
||||||
p_create.add_argument("--positions")
|
p_create.add_argument("--positions")
|
||||||
p_create.add_argument("--pending")
|
p_create.add_argument("--pending")
|
||||||
|
|
||||||
# projects
|
|
||||||
sub.add_parser("projects", help="List projects")
|
sub.add_parser("projects", help="List projects")
|
||||||
|
|
||||||
# users
|
|
||||||
sub.add_parser("users", help="List users")
|
sub.add_parser("users", help="List users")
|
||||||
|
|
||||||
# version
|
|
||||||
sub.add_parser("version", help="Show version")
|
sub.add_parser("version", help="Show version")
|
||||||
|
|
||||||
# health
|
|
||||||
sub.add_parser("health", help="Health check")
|
sub.add_parser("health", help="Health check")
|
||||||
|
|
||||||
|
p_search = sub.add_parser("search", help="Search tasks")
|
||||||
# search
|
|
||||||
p_search = sub.add_parser("search", help="Search issues")
|
|
||||||
p_search.add_argument("query")
|
p_search.add_argument("query")
|
||||||
p_search.add_argument("--project", "-p", type=int)
|
p_search.add_argument("--project", "-p", type=int)
|
||||||
|
|
||||||
# transition
|
p_trans = sub.add_parser("transition", help="Transition task status")
|
||||||
p_trans = sub.add_parser("transition", help="Transition issue status")
|
p_trans.add_argument("task_id", type=int)
|
||||||
p_trans.add_argument("issue_id", type=int)
|
p_trans.add_argument("status", choices=["open", "pending", "progressing", "closed"])
|
||||||
p_trans.add_argument("status", choices=["open", "in_progress", "resolved", "closed", "blocked"])
|
|
||||||
|
|
||||||
# stats
|
|
||||||
p_stats = sub.add_parser("stats", help="Dashboard stats")
|
p_stats = sub.add_parser("stats", help="Dashboard stats")
|
||||||
p_stats.add_argument("--project", "-p", type=int)
|
p_stats.add_argument("--project", "-p", type=int)
|
||||||
|
|
||||||
|
|
||||||
# milestones
|
|
||||||
p_ms = sub.add_parser("milestones", help="List milestones")
|
p_ms = sub.add_parser("milestones", help="List milestones")
|
||||||
p_ms.add_argument("--project", "-p", type=int)
|
p_ms.add_argument("--project", "-p", type=int)
|
||||||
|
|
||||||
# milestone progress
|
|
||||||
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
|
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
|
||||||
p_msp.add_argument("milestone_id", type=int)
|
p_msp.add_argument("milestone_id", type=int)
|
||||||
|
|
||||||
# notifications
|
p_notif = sub.add_parser("notifications", help="List notifications for current token user")
|
||||||
p_notif = sub.add_parser("notifications", help="List notifications")
|
|
||||||
p_notif.add_argument("--user", "-u", type=int, required=True)
|
|
||||||
p_notif.add_argument("--unread", action="store_true")
|
p_notif.add_argument("--unread", action="store_true")
|
||||||
|
|
||||||
# overdue
|
p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status")
|
||||||
p_overdue = sub.add_parser("overdue", help="List overdue issues")
|
|
||||||
p_overdue.add_argument("--project", "-p", type=int)
|
p_overdue.add_argument("--project", "-p", type=int)
|
||||||
|
|
||||||
p_logtime = sub.add_parser('log-time', help='Log time on an issue')
|
p_logtime = sub.add_parser("log-time", help="Log time on a task")
|
||||||
p_logtime.add_argument('issue_id', type=int)
|
p_logtime.add_argument("task_id", type=int)
|
||||||
p_logtime.add_argument('user_id', type=int)
|
p_logtime.add_argument("user_id", type=int)
|
||||||
p_logtime.add_argument('hours', type=float)
|
p_logtime.add_argument("hours", type=float)
|
||||||
p_logtime.add_argument('--desc', '-d', type=str)
|
p_logtime.add_argument("--desc", "-d", type=str)
|
||||||
|
|
||||||
p_worklogs = sub.add_parser('worklogs', help='List work logs for an issue')
|
p_worklogs = sub.add_parser("worklogs", help="List work logs for a task")
|
||||||
p_worklogs.add_argument('issue_id', type=int)
|
p_worklogs.add_argument("task_id", type=int)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not args.command:
|
if not args.command:
|
||||||
@@ -307,8 +301,10 @@ def main():
|
|||||||
|
|
||||||
cmds = {
|
cmds = {
|
||||||
"login": cmd_login,
|
"login": cmd_login,
|
||||||
"issues": cmd_issues,
|
"tasks": cmd_tasks,
|
||||||
"create-issue": cmd_issue_create,
|
"issues": cmd_tasks,
|
||||||
|
"create-task": cmd_task_create,
|
||||||
|
"create-issue": cmd_task_create,
|
||||||
"projects": cmd_projects,
|
"projects": cmd_projects,
|
||||||
"users": cmd_users,
|
"users": cmd_users,
|
||||||
"version": cmd_version,
|
"version": cmd_version,
|
||||||
@@ -327,5 +323,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import urllib.parse
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user