Merge pull request 'Merge dev-2026-03-22 into main' (#12) from dev-2026-03-22 into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
h z
2026-03-22 14:12:43 +00:00
18 changed files with 1297 additions and 661 deletions

View File

@@ -98,29 +98,9 @@ Agent/人类协同任务管理平台 - FastAPI 后端
## CLI ## CLI
```bash The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI instead.
# 环境变量
export HARBORFORGE_URL=http://localhost:8000
export HARBORFORGE_TOKEN=<your-token>
# 命令 See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
python3 cli.py login <username> <password>
python3 cli.py issues [-p project_id] [-t type] [-s status]
python3 cli.py create-issue "title" -p 1 -r 1 [-t resolution --summary "..." --positions "..." --pending "..."]
python3 cli.py search "keyword"
python3 cli.py transition <issue_id> <new_status>
python3 cli.py stats [-p project_id]
python3 cli.py projects
python3 cli.py users
python3 cli.py milestones [-p project_id]
python3 cli.py milestone-progress <milestone_id>
python3 cli.py notifications -u <user_id> [--unread]
python3 cli.py overdue [-p project_id]
python3 cli.py log-time <issue_id> <user_id> <hours> [-d "description"]
python3 cli.py worklogs <issue_id>
python3 cli.py health
python3 cli.py version
```
## 技术栈 ## 技术栈

View File

@@ -20,8 +20,8 @@ def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
# Check global admin # Check global admin
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if user and user.is_admin: if user and user.is_admin:
# Return global admin role # Return global admin role (name="admin")
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first() return db.query(Role).filter(Role.is_global == True, Role.name == "admin").first()
return None return None

View File

@@ -1,11 +1,15 @@
"""Auth router.""" """Auth router."""
from datetime import timedelta from datetime import timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_db, settings from app.core.config import get_db, settings
from app.models import models from app.models import models
from app.models.role_permission import Permission, Role, RolePermission
from app.schemas import schemas from app.schemas import schemas
from app.api.deps import Token, verify_password, create_access_token, get_current_user from app.api.deps import Token, verify_password, create_access_token, get_current_user
@@ -20,6 +24,9 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
headers={"WWW-Authenticate": "Bearer"}) headers={"WWW-Authenticate": "Bearer"})
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
# Built-in acc-mgr account cannot log in interactively
if user.username == "acc-mgr":
raise HTTPException(status_code=403, detail="This account cannot log in")
access_token = create_access_token( access_token = create_access_token(
data={"sub": str(user.id)}, data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
@@ -30,3 +37,74 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
@router.get("/me", response_model=schemas.UserResponse) @router.get("/me", response_model=schemas.UserResponse)
async def get_me(current_user: models.User = Depends(get_current_user)): async def get_me(current_user: models.User = Depends(get_current_user)):
return current_user return current_user
class ApiKeyPermissionResponse(BaseModel):
can_reset_self: bool
can_reset_any: bool
@router.get("/me/apikey-permissions", response_model=ApiKeyPermissionResponse)
async def get_apikey_permissions(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the current user's API key reset capabilities."""
def _has_perm(perm_name: str) -> bool:
if current_user.is_admin:
return True
if not current_user.role_id:
return False
perm = db.query(Permission).filter(Permission.name == perm_name).first()
if not perm:
return False
return db.query(RolePermission).filter(
RolePermission.role_id == current_user.role_id,
RolePermission.permission_id == perm.id,
).first() is not None
return ApiKeyPermissionResponse(
can_reset_self=_has_perm("user.reset-self-apikey"),
can_reset_any=_has_perm("user.reset-apikey"),
)
class PermissionIntrospectionResponse(BaseModel):
username: str
role_name: str | None
is_admin: bool
permissions: List[str]
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
async def get_my_permissions(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the current user's effective permissions for CLI help introspection."""
perms: List[str] = []
role_name: str | None = None
if current_user.is_admin:
# Admin gets all permissions
all_perms = db.query(Permission).order_by(Permission.name).all()
perms = [p.name for p in all_perms]
role_name = "admin"
elif current_user.role_id:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role:
role_name = role.name
perm_ids = db.query(RolePermission.permission_id).filter(
RolePermission.role_id == role.id
).all()
if perm_ids:
pid_list = [p[0] for p in perm_ids]
matched = db.query(Permission).filter(Permission.id.in_(pid_list)).order_by(Permission.name).all()
perms = [p.name for p in matched]
return PermissionIntrospectionResponse(
username=current_user.username,
role_name=role_name,
is_admin=current_user.is_admin,
permissions=perms,
)

View File

@@ -50,9 +50,30 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
return db_comment return db_comment
@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse]) @router.get("/tasks/{task_id}/comments")
def list_comments(task_id: int, db: Session = Depends(get_db)): def list_comments(task_id: str, db: Session = Depends(get_db)):
return db.query(models.Comment).filter(models.Comment.task_id == task_id).all() """List comments for a task. task_id can be numeric id or task_code."""
try:
tid = int(task_id)
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
tid = task.id
comments = db.query(models.Comment).filter(models.Comment.task_id == tid).all()
result = []
for c in comments:
author = db.query(models.User).filter(models.User.id == c.author_id).first()
result.append({
"id": c.id,
"content": c.content,
"task_id": c.task_id,
"author_id": c.author_id,
"author_username": author.username if author else None,
"created_at": c.created_at,
"updated_at": c.updated_at,
})
return result
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) @router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)

289
app/api/routers/meetings.py Normal file
View File

@@ -0,0 +1,289 @@
"""Meetings router — code-first CRUD with participant/attend support."""
import math
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.config import get_db
from app.models import models
from app.models.meeting import Meeting, MeetingStatus, MeetingPriority, MeetingParticipant
from app.models.milestone import Milestone
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
router = APIRouter(tags=["Meetings"])
# ---- helpers ----
def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None:
try:
mid = int(identifier)
meeting = db.query(Meeting).filter(Meeting.id == mid).first()
if meeting:
return meeting
except (ValueError, TypeError):
pass
return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first()
def _resolve_project_id(db: Session, project_code: str | None) -> int | None:
if not project_code:
return None
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")
return project.id
def _resolve_milestone(db: Session, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if not milestone_code:
return None
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
ms = query.first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
def _get_participant_usernames(db: Session, meeting: Meeting) -> list[str]:
parts = db.query(MeetingParticipant).filter(MeetingParticipant.meeting_id == meeting.id).all()
usernames = []
for p in parts:
user = db.query(models.User).filter(models.User.id == p.user_id).first()
if user:
usernames.append(user.username)
return usernames
def _serialize_meeting(db: Session, meeting: Meeting) -> dict:
project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first()
return {
"id": meeting.id,
"code": meeting.meeting_code,
"meeting_code": meeting.meeting_code,
"title": meeting.title,
"description": meeting.description,
"status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status,
"priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority,
"project_id": meeting.project_id,
"project_code": project.project_code if project else None,
"milestone_id": meeting.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": meeting.reporter_id,
"meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
"scheduled_at": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None,
"duration_minutes": meeting.duration_minutes,
"participants": _get_participant_usernames(db, meeting),
"created_at": meeting.created_at.isoformat() if meeting.created_at else None,
"updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None,
}
# ---- CRUD ----
class MeetingCreateBody(BaseModel):
project_code: str
title: str
milestone_code: Optional[str] = None
description: Optional[str] = None
meeting_time: Optional[str] = None
duration_minutes: Optional[int] = None
@router.post("/meetings", status_code=status.HTTP_201_CREATED)
def create_meeting(
body: MeetingCreateBody,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
project_id = _resolve_project_id(db, body.project_code)
if not project_id:
raise HTTPException(status_code=400, detail="project_code is required")
check_project_role(db, current_user.id, project_id, min_role="dev")
milestone = _resolve_milestone(db, body.milestone_code, project_id)
if not milestone:
# If no milestone_code, try to use the first active milestone
milestone = db.query(Milestone).filter(
Milestone.project_id == project_id,
).order_by(Milestone.id.desc()).first()
if not milestone:
raise HTTPException(status_code=400, detail="No milestone available for project")
milestone_code = milestone.milestone_code or f"m{milestone.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1
meeting_code = f"{milestone_code}:M{next_num:05x}"
scheduled_at = None
if body.meeting_time:
try:
scheduled_at = datetime.fromisoformat(body.meeting_time.replace("Z", "+00:00"))
except Exception:
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
meeting = Meeting(
title=body.title,
description=body.description,
status=MeetingStatus.SCHEDULED,
priority=MeetingPriority.MEDIUM,
project_id=project_id,
milestone_id=milestone.id,
reporter_id=current_user.id,
meeting_code=meeting_code,
scheduled_at=scheduled_at,
duration_minutes=body.duration_minutes,
)
db.add(meeting)
db.commit()
db.refresh(meeting)
# Auto-add creator as participant
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
db.add(participant)
db.commit()
return _serialize_meeting(db, meeting)
@router.get("/meetings")
def list_meetings(
project: str = None,
status_value: str = Query(None, alias="status"),
order_by: str = None,
page: int = 1,
page_size: int = 50,
db: Session = Depends(get_db),
):
query = db.query(Meeting)
if project:
project_id = _resolve_project_id(db, project)
if project_id:
query = query.filter(Meeting.project_id == project_id)
if status_value:
query = query.filter(Meeting.status == status_value)
sort_fields = {
"created": Meeting.created_at,
"created_at": Meeting.created_at,
"due-date": Meeting.scheduled_at,
"scheduled_at": Meeting.scheduled_at,
"name": Meeting.title,
"title": Meeting.title,
}
sort_col = sort_fields.get(order_by, Meeting.created_at)
query = query.order_by(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": [_serialize_meeting(db, m) for m in items],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/meetings/{meeting_id}")
def get_meeting(meeting_id: str, db: Session = Depends(get_db)):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
return _serialize_meeting(db, meeting)
class MeetingUpdateBody(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
meeting_time: Optional[str] = None
duration_minutes: Optional[int] = None
@router.patch("/meetings/{meeting_id}")
def update_meeting(
meeting_id: str,
body: MeetingUpdateBody,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No supported fields to update")
if "title" in update_data and update_data["title"] is not None:
meeting.title = update_data["title"]
if "description" in update_data:
meeting.description = update_data["description"]
if "status" in update_data and update_data["status"] is not None:
meeting.status = MeetingStatus(update_data["status"])
if "meeting_time" in update_data and update_data["meeting_time"] is not None:
try:
meeting.scheduled_at = datetime.fromisoformat(update_data["meeting_time"].replace("Z", "+00:00"))
except Exception:
raise HTTPException(status_code=400, detail="Invalid meeting_time format")
if "duration_minutes" in update_data:
meeting.duration_minutes = update_data["duration_minutes"]
db.commit()
db.refresh(meeting)
return _serialize_meeting(db, meeting)
@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="dev")
db.delete(meeting)
db.commit()
return None
# ---- Attend ----
@router.post("/meetings/{meeting_id}/attend")
def attend_meeting(
meeting_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
meeting = _find_meeting_by_id_or_code(db, meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
check_project_role(db, current_user.id, meeting.project_id, min_role="viewer")
existing = db.query(MeetingParticipant).filter(
MeetingParticipant.meeting_id == meeting.id,
MeetingParticipant.user_id == current_user.id,
).first()
if existing:
return _serialize_meeting(db, meeting)
participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id)
db.add(participant)
db.commit()
return _serialize_meeting(db, meeting)

View File

@@ -18,8 +18,38 @@ from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"]) router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
def _find_project(db, identifier) -> models.Project | None:
"""Look up project by numeric id or project_code."""
try:
pid = int(identifier)
p = db.query(models.Project).filter(models.Project.id == pid).first()
if p:
return p
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
"""Look up milestone by numeric id or milestone_code."""
try:
mid = int(identifier)
q = db.query(Milestone).filter(Milestone.id == mid)
if project_id:
q = q.filter(Milestone.project_id == project_id)
ms = q.first()
if ms:
return ms
except (ValueError, TypeError):
pass
q = db.query(Milestone).filter(Milestone.milestone_code == str(identifier))
if project_id:
q = q.filter(Milestone.project_id == project_id)
return q.first()
def _serialize_milestone(milestone): def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields.""" """Serialize milestone with JSON fields and code."""
return { return {
"id": milestone.id, "id": milestone.id,
"title": milestone.title, "title": milestone.title,
@@ -30,6 +60,8 @@ def _serialize_milestone(milestone):
"depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [], "depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [],
"depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [], "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id, "project_id": milestone.project_id,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"created_by_id": milestone.created_by_id, "created_by_id": milestone.created_by_id,
"started_at": milestone.started_at, "started_at": milestone.started_at,
"created_at": milestone.created_at, "created_at": milestone.created_at,
@@ -38,19 +70,24 @@ def _serialize_milestone(milestone):
@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: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
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]
@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: str, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="mgr")
project = db.query(models.Project).filter(models.Project.id == project_id).first() project_code = project.project_code if project.project_code 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()
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}"
@@ -60,7 +97,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
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"):
data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"]) data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"])
db_milestone = Milestone(project_id=project_id, milestone_code=milestone_code, created_by_id=current_user.id, **data) db_milestone = Milestone(project_id=project.id, milestone_code=milestone_code, created_by_id=current_user.id, **data)
db.add(db_milestone) db.add(db_milestone)
db.commit() db.commit()
db.refresh(db_milestone) db.refresh(db_milestone)
@@ -68,17 +105,23 @@ 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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return _serialize_milestone(milestone) return _serialize_milestone(milestone)
@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: str, milestone_id: str, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
db_milestone = _find_milestone(db, milestone_id, project.id)
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")
ensure_can_edit_milestone(db, current_user.id, db_milestone) ensure_can_edit_milestone(db, current_user.id, db_milestone)
@@ -124,9 +167,12 @@ 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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="admin") project = _find_project(db, project_id)
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="admin")
db_milestone = _find_milestone(db, milestone_id, project.id)
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")
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
@@ -138,9 +184,12 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"]) @router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
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)): def create_milestone_task(project_id: str, milestone_id: str, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="dev") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -177,8 +226,8 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
task_subtype=data.get("task_subtype"), task_subtype=data.get("task_subtype"),
status=TaskStatus.PENDING, status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM, priority=TaskPriority.MEDIUM,
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,
task_code=task_code, task_code=task_code,
estimated_effort=data.get("estimated_effort"), estimated_effort=data.get("estimated_effort"),
@@ -192,15 +241,18 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
@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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.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() supports = db.query(Support).filter(Support.milestone_id == milestone.id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all() meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).all()
return { return {
"tasks": [{ "tasks": [{
@@ -221,13 +273,16 @@ def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depend
@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: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all() all_tasks = db.query(Task).filter(Task.milestone_id == milestone.id).all()
total = len(all_tasks) total = len(all_tasks)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED) completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
progress_pct = (completed / total * 100) if total > 0 else 0 progress_pct = (completed / total * 100) if total > 0 else 0
@@ -241,7 +296,8 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
time_progress = min(100, max(0, (elapsed / total_duration * 100))) time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return { return {
"milestone_id": milestone_id, "milestone_id": milestone.id,
"milestone_code": milestone.milestone_code,
"title": milestone.title, "title": milestone.title,
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,

View File

@@ -13,7 +13,7 @@ from pydantic import BaseModel
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 ensure_can_edit_milestone from app.api.rbac import check_project_role, ensure_can_edit_milestone
from app.models import models from app.models import models
from app.models.apikey import APIKey from app.models.apikey import APIKey
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
@@ -28,6 +28,19 @@ from app.schemas import schemas
router = APIRouter() router = APIRouter()
def _resolve_milestone(db: Session, identifier: str) -> MilestoneModel:
"""Resolve a milestone by numeric id or milestone_code string.
Raises 404 if not found."""
try:
ms_id = int(identifier)
ms = db.query(MilestoneModel).filter(MilestoneModel.id == ms_id).first()
except (ValueError, TypeError):
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == identifier).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
# ============ API Keys ============ # ============ API Keys ============
class APIKeyCreate(BaseModel): class APIKeyCreate(BaseModel):
@@ -136,28 +149,46 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db),
@router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) @router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"])
def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)):
query = db.query(MilestoneModel) query = db.query(MilestoneModel)
if project_id: if project_id:
query = query.filter(MilestoneModel.project_id == project_id) # Resolve project_id by numeric id or project_code
resolved_project = None
try:
pid = int(project_id)
resolved_project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError):
pass
if not resolved_project:
resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first()
if not resolved_project:
raise HTTPException(status_code=404, detail="Project not found")
query = query.filter(MilestoneModel.project_id == resolved_project.id)
if status_filter: if status_filter:
query = query.filter(MilestoneModel.status == status_filter) query = query.filter(MilestoneModel.status == status_filter)
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
def _find_milestone_by_id_or_code(db, identifier) -> MilestoneModel | None:
"""Look up milestone by numeric id or milestone_code."""
try:
mid = int(identifier)
ms = db.query(MilestoneModel).filter(MilestoneModel.id == mid).first()
if ms:
return ms
except (ValueError, TypeError):
pass
return db.query(MilestoneModel).filter(MilestoneModel.milestone_code == str(identifier)).first()
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) @router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def get_milestone(milestone_id: int, db: Session = Depends(get_db)): def get_milestone(milestone_id: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() return _resolve_milestone(db, milestone_id)
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
return ms
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) @router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, 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() ms = _resolve_milestone(db, milestone_id)
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
ensure_can_edit_milestone(db, current_user.id, ms) ensure_can_edit_milestone(db, current_user.id, ms)
for field, value in ms_update.model_dump(exclude_unset=True).items(): for field, value in ms_update.model_dump(exclude_unset=True).items():
setattr(ms, field, value) setattr(ms, field, value)
@@ -167,21 +198,17 @@ def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db:
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"]) @router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _resolve_milestone(db, milestone_id)
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
db.delete(ms) db.delete(ms)
db.commit() db.commit()
return None return None
@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: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _resolve_milestone(db, milestone_id)
if not ms: tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
total = len(tasks) total = len(tasks)
done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED) done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED)
@@ -193,7 +220,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
time_progress = min(100, max(0, (elapsed / total_duration * 100))) time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return { return {
"milestone_id": milestone_id, "milestone_id": ms.id,
"title": ms.title, "title": ms.title,
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,
@@ -311,18 +338,34 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
@router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"]) @router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"])
def list_task_worklogs(task_id: int, db: Session = Depends(get_db)): def list_task_worklogs(task_id: str, db: Session = Depends(get_db)):
return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all() """List worklogs for a task. task_id can be numeric id or task_code."""
try:
tid = int(task_id)
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
tid = task.id
return db.query(WorkLog).filter(WorkLog.task_id == tid).order_by(WorkLog.logged_date.desc()).all()
@router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"]) @router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"])
def task_worklog_summary(task_id: int, db: Session = Depends(get_db)): def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() """Worklog summary for a task. task_id can be numeric id or task_code."""
try:
tid = int(task_id)
except (ValueError, TypeError):
t = db.query(Task).filter(Task.task_code == task_id).first()
if not t:
raise HTTPException(status_code=404, detail="Task not found")
tid = t.id
task = db.query(Task).filter(Task.id == tid).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0 total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == tid).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count() count = db.query(WorkLog).filter(WorkLog.task_id == tid).count()
return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count} return {"task_id": tid, "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"])
@@ -472,26 +515,87 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict,
# ============ Supports ============ # ============ Supports ============
def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None:
try:
support_id = int(identifier)
support = db.query(Support).filter(Support.id == support_id).first()
if support:
return support
except (TypeError, ValueError):
pass
return db.query(Support).filter(Support.support_code == str(identifier)).first()
def _serialize_support(db: Session, support: Support) -> dict:
project = db.query(models.Project).filter(models.Project.id == support.project_id).first()
milestone = db.query(MilestoneModel).filter(MilestoneModel.id == support.milestone_id).first()
assignee = None
if support.assignee_id:
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
return {
"id": support.id,
"code": support.support_code,
"support_code": support.support_code,
"title": support.title,
"description": support.description,
"status": support.status.value if hasattr(support.status, "value") else support.status,
"priority": support.priority.value if hasattr(support.priority, "value") else support.priority,
"project_id": support.project_id,
"project_code": project.project_code if project else None,
"milestone_id": support.milestone_id,
"milestone_code": milestone.milestone_code if milestone else None,
"reporter_id": support.reporter_id,
"assignee_id": support.assignee_id,
"taken_by": assignee.username if assignee else None,
"created_at": support.created_at,
"updated_at": support.updated_at,
}
@router.get("/supports", tags=["Supports"])
def list_all_supports(
status: str | None = None,
taken_by: str | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""List support tickets across all projects. Optional status/taken_by filters."""
query = db.query(Support)
if status:
query = query.filter(Support.status == SupportStatus(status))
if taken_by == "me":
query = query.filter(Support.assignee_id == current_user.id)
elif taken_by == "null":
query = query.filter(Support.assignee_id.is_(None))
elif taken_by:
assignee = db.query(models.User).filter(models.User.username == taken_by).first()
if assignee:
query = query.filter(Support.assignee_id == assignee.id)
else:
return []
query = query.order_by(Support.created_at.desc())
supports = query.all()
return [_serialize_support(db, s) for s in supports]
@router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"]) @router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"])
def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)): def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.project_code == project_code).first() 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")
supports = db.query(Support).filter( supports = db.query(Support).filter(
Support.project_id == project.id, Support.project_id == project.id,
Support.milestone_id == milestone_id Support.milestone_id == milestone_id
).all() ).all()
return [{ return [_serialize_support(db, s) for s in supports]
"id": s.id,
"title": s.title,
"description": s.description,
"status": s.status.value,
"priority": s.priority.value,
"assignee_id": s.assignee_id,
"created_at": s.created_at,
} for s in supports]
@router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"]) @router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"])
@@ -499,19 +603,19 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, 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")
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")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing": if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
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
support_code = f"{milestone_code}:S{next_num:05x}" support_code = f"{milestone_code}:S{next_num:05x}"
support = Support( support = Support(
title=support_data.get("title"), title=support_data.get("title"),
description=support_data.get("description"), description=support_data.get("description"),
@@ -525,7 +629,89 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db:
db.add(support) db.add(support)
db.commit() db.commit()
db.refresh(support) db.refresh(support)
return support return _serialize_support(db, support)
@router.get("/supports/{support_id}", tags=["Supports"])
def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="viewer")
return _serialize_support(db, support)
@router.patch("/supports/{support_id}", tags=["Supports"])
def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
allowed_fields = {"title", "description", "status", "priority"}
updated = False
for field, value in support_data.items():
if field not in allowed_fields:
continue
if field == "status" and value is not None:
value = SupportStatus(value)
if field == "priority" and value is not None:
value = SupportPriority(value)
setattr(support, field, value)
updated = True
if not updated:
raise HTTPException(status_code=400, detail="No supported fields to update")
db.commit()
db.refresh(support)
return _serialize_support(db, support)
@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"])
def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
db.delete(support)
db.commit()
return None
@router.post("/supports/{support_id}/take", tags=["Supports"])
def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
if support.assignee_id and support.assignee_id != current_user.id:
assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first()
assignee_name = assignee.username if assignee else str(support.assignee_id)
raise HTTPException(status_code=409, detail=f"Support is already taken by {assignee_name}")
support.assignee_id = current_user.id
db.commit()
db.refresh(support)
return _serialize_support(db, support)
@router.post("/supports/{support_id}/transition", tags=["Supports"])
def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
support = _find_support_by_id_or_code(db, support_id)
if not support:
raise HTTPException(status_code=404, detail="Support not found")
check_project_role(db, current_user.id, support.project_id, min_role="dev")
status_value = support_data.get("status")
if not status_value:
raise HTTPException(status_code=400, detail="status is required")
support.status = SupportStatus(status_value)
db.commit()
db.refresh(support)
return _serialize_support(db, support)
# ============ Meetings ============ # ============ Meetings ============

View File

@@ -15,6 +15,19 @@ from app.api.rbac import check_project_role, check_permission, ensure_can_edit_p
router = APIRouter(prefix="/projects", tags=["Projects"]) router = APIRouter(prefix="/projects", tags=["Projects"])
def _resolve_project(db: Session, identifier: str) -> models.Project:
"""Resolve a project by numeric id or project_code string.
Raises 404 if not found."""
try:
pid = int(identifier)
project = db.query(models.Project).filter(models.Project.id == pid).first()
except (ValueError, TypeError):
project = db.query(models.Project).filter(models.Project.project_code == identifier).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None: def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
if not codes: if not codes:
return None return None
@@ -182,24 +195,31 @@ def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
return db.query(models.Project).offset(skip).limit(limit).all() return db.query(models.Project).offset(skip).limit(limit).all()
def _find_project_by_id_or_code(db, identifier) -> models.Project | None:
"""Look up project by numeric id or project_code."""
try:
pid = int(identifier)
project = db.query(models.Project).filter(models.Project.id == pid).first()
if project:
return project
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
@router.get("/{project_id}", response_model=schemas.ProjectResponse) @router.get("/{project_id}", response_model=schemas.ProjectResponse)
def get_project(project_id: int, db: Session = Depends(get_db)): def get_project(project_id: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.id == project_id).first() return _resolve_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.patch("/{project_id}", response_model=schemas.ProjectResponse) @router.patch("/{project_id}", response_model=schemas.ProjectResponse)
def update_project( def update_project(
project_id: int, project_id: str,
project_update: schemas.ProjectUpdate, project_update: schemas.ProjectUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
project = db.query(models.Project).filter(models.Project.id == project_id).first() project = _resolve_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ensure_can_edit_project(db, current_user.id, project) ensure_can_edit_project(db, current_user.id, project)
update_data = project_update.model_dump(exclude_unset=True) update_data = project_update.model_dump(exclude_unset=True)
update_data.pop("name", None) update_data.pop("name", None)
@@ -220,21 +240,20 @@ def update_project(
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project( def delete_project(
project_id: int, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="admin") project = _resolve_project(db, project_id)
project = db.query(models.Project).filter(models.Project.id == project_id).first() check_project_role(db, current_user.id, project.id, min_role="admin")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_code = project.project_code project_code = project.project_code
project_id_val = project.id
# Delete milestones and their tasks # Delete milestones and their tasks
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.models.task import Task 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_val).all()
for ms in milestones: for ms in milestones:
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all() tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks: for task in tasks:
@@ -242,7 +261,7 @@ def delete_project(
db.delete(ms) db.delete(ms)
# Delete project members # Delete project members
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
for m in members: for m in members:
db.delete(m) db.delete(m)
@@ -269,27 +288,25 @@ def delete_project(
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) @router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
def add_project_member( def add_project_member(
project_id: int, project_id: str,
member: schemas.ProjectMemberCreate, member: schemas.ProjectMemberCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _resolve_project(db, project_id)
project = db.query(models.Project).filter(models.Project.id == project_id).first() check_project_role(db, current_user.id, project.id, min_role="mgr")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
user = db.query(models.User).filter(models.User.id == member.user_id).first() user = db.query(models.User).filter(models.User.id == member.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")
existing = db.query(models.ProjectMember).filter( existing = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == member.user_id models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == member.user_id
).first() ).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="User already a member") raise HTTPException(status_code=400, detail="User already a member")
# Convert role name to role_id # Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first() role = db.query(Role).filter(Role.name == member.role).first()
role_id = role.id if role else None role_id = role.id if role else None
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role_id=role_id) db_member = models.ProjectMember(project_id=project.id, user_id=member.user_id, role_id=role_id)
db.add(db_member) db.add(db_member)
db.commit() db.commit()
db.refresh(db_member) db.refresh(db_member)
@@ -307,8 +324,9 @@ def add_project_member(
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) @router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)): def list_project_members(project_id: str, db: Session = Depends(get_db)):
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() project = _resolve_project(db, project_id)
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
result = [] result = []
for m in members: for m in members:
role_name = "developer" role_name = "developer"
@@ -316,9 +334,12 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)):
role = db.query(Role).filter(Role.id == m.role_id).first() role = db.query(Role).filter(Role.id == m.role_id).first()
if role: if role:
role_name = role.name role_name = role.name
user = db.query(models.User).filter(models.User.id == m.user_id).first()
result.append({ result.append({
"id": m.id, "id": m.id,
"user_id": m.user_id, "user_id": m.user_id,
"username": user.username if user else None,
"full_name": user.full_name if user else None,
"project_id": m.project_id, "project_id": m.project_id,
"role": role_name "role": role_name
}) })
@@ -327,14 +348,15 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)):
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_project_member( def remove_project_member(
project_id: int, project_id: str,
user_id: int, user_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_permission(db, current_user.id, project_id, "member.remove") project = _resolve_project(db, project_id)
check_permission(db, current_user.id, project.id, "member.remove")
member = db.query(models.ProjectMember).filter( member = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == user_id
).first() ).first()
# Prevent removing project owner (admin role) # Prevent removing project owner (admin role)
@@ -362,16 +384,18 @@ 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: str, db: Session = Depends(get_db)):
from app.models.task import Task as TaskModel from app.models.task import Task as TaskModel
project = _resolve_project(db, project_id)
resolved_project_id = project.id
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(TaskModel, WorkLog.task_id == TaskModel.id)\ .join(TaskModel, WorkLog.task_id == TaskModel.id)\
.filter(TaskModel.project_id == project_id)\ .filter(TaskModel.project_id == resolved_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]
return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user} return {"project_id": resolved_project_id, "total_hours": round(total, 2), "by_user": by_user}

View File

@@ -17,6 +17,54 @@ from app.services.activity import log_activity
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"]) router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
def _serialize_propose(db: Session, propose: Propose) -> dict:
"""Serialize propose with created_by_username."""
creator = db.query(models.User).filter(models.User.id == propose.created_by_id).first() if propose.created_by_id else None
return {
"id": propose.id,
"title": propose.title,
"description": propose.description,
"propose_code": propose.propose_code,
"status": propose.status.value if hasattr(propose.status, "value") else propose.status,
"project_id": propose.project_id,
"created_by_id": propose.created_by_id,
"created_by_username": creator.username if creator else None,
"feat_task_id": propose.feat_task_id,
"created_at": propose.created_at,
"updated_at": propose.updated_at,
}
def _find_project(db, identifier):
"""Look up project by numeric id or project_code."""
try:
pid = int(identifier)
p = db.query(models.Project).filter(models.Project.id == pid).first()
if p:
return p
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
def _find_propose(db, identifier, project_id: int = None) -> Propose | None:
"""Look up propose by numeric id or propose_code."""
try:
pid = int(identifier)
q = db.query(Propose).filter(Propose.id == pid)
if project_id:
q = q.filter(Propose.project_id == project_id)
p = q.first()
if p:
return p
except (ValueError, TypeError):
pass
q = db.query(Propose).filter(Propose.propose_code == str(identifier))
if project_id:
q = q.filter(Propose.project_id == project_id)
return q.first()
def _generate_propose_code(db: Session, project_id: int) -> str: def _generate_propose_code(db: Session, project_id: int) -> str:
"""Generate next propose code: {proj_code}:P{i:05x}""" """Generate next propose code: {proj_code}:P{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()
@@ -48,36 +96,42 @@ def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool:
@router.get("", response_model=List[schemas.ProposeResponse]) @router.get("", response_model=List[schemas.ProposeResponse])
def list_proposes( def list_proposes(
project_id: int, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
proposes = ( proposes = (
db.query(Propose) db.query(Propose)
.filter(Propose.project_id == project_id) .filter(Propose.project_id == project.id)
.order_by(Propose.id.desc()) .order_by(Propose.id.desc())
.all() .all()
) )
return proposes return [_serialize_propose(db, p) for p in proposes]
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
def create_propose( def create_propose(
project_id: int, project_id: str,
propose_in: schemas.ProposeCreate, propose_in: schemas.ProposeCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="dev") project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
propose_code = _generate_propose_code(db, project_id) propose_code = _generate_propose_code(db, project.id)
propose = Propose( propose = Propose(
title=propose_in.title, title=propose_in.title,
description=propose_in.description, description=propose_in.description,
status=ProposeStatus.OPEN, status=ProposeStatus.OPEN,
project_id=project_id, project_id=project.id,
created_by_id=current_user.id, created_by_id=current_user.id,
propose_code=propose_code, propose_code=propose_code,
) )
@@ -87,32 +141,38 @@ def create_propose(
log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title}) log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title})
return propose return _serialize_propose(db, propose)
@router.get("/{propose_id}", response_model=schemas.ProposeResponse) @router.get("/{propose_id}", response_model=schemas.ProposeResponse)
def get_propose( def get_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
return propose return _serialize_propose(db, propose)
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse) @router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
def update_propose( def update_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
propose_in: schemas.ProposeUpdate, propose_in: schemas.ProposeUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -135,7 +195,7 @@ def update_propose(
log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data) log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data)
return propose return _serialize_propose(db, propose)
# ---- Actions ---- # ---- Actions ----
@@ -146,14 +206,17 @@ class AcceptRequest(schemas.BaseModel):
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
def accept_propose( def accept_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Accept a propose: create a feature story task in the chosen milestone.""" """Accept a propose: create a feature story task in the chosen milestone."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -161,12 +224,12 @@ def accept_propose(
if propose_status != "open": if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be accepted") raise HTTPException(status_code=400, detail="Only open proposes can be accepted")
check_permission(db, current_user.id, project_id, "propose.accept") check_permission(db, current_user.id, project.id, "propose.accept")
# Validate milestone # Validate milestone
milestone = db.query(Milestone).filter( milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id, Milestone.id == body.milestone_id,
Milestone.project_id == project_id, Milestone.project_id == project.id,
).first() ).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found in this project") raise HTTPException(status_code=404, detail="Milestone not found in this project")
@@ -189,7 +252,7 @@ def accept_propose(
task_subtype="feature", task_subtype="feature",
status=TaskStatus.PENDING, status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM, priority=TaskPriority.MEDIUM,
project_id=project_id, project_id=project.id,
milestone_id=milestone.id, milestone_id=milestone.id,
reporter_id=propose.created_by_id or current_user.id, reporter_id=propose.created_by_id or current_user.id,
created_by_id=propose.created_by_id or current_user.id, created_by_id=propose.created_by_id or current_user.id,
@@ -211,7 +274,7 @@ def accept_propose(
"task_code": task_code, "task_code": task_code,
}) })
return propose return _serialize_propose(db, propose)
class RejectRequest(schemas.BaseModel): class RejectRequest(schemas.BaseModel):
@@ -220,14 +283,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
def reject_propose( def reject_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reject a propose.""" """Reject a propose."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -235,7 +301,7 @@ def reject_propose(
if propose_status != "open": if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be rejected") raise HTTPException(status_code=400, detail="Only open proposes can be rejected")
check_permission(db, current_user.id, project_id, "propose.reject") check_permission(db, current_user.id, project.id, "propose.reject")
propose.status = ProposeStatus.REJECTED propose.status = ProposeStatus.REJECTED
db.commit() db.commit()
@@ -245,18 +311,21 @@ def reject_propose(
"reason": body.reason if body else None, "reason": body.reason if body else None,
}) })
return propose return _serialize_propose(db, propose)
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
def reopen_propose( def reopen_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reopen a rejected propose back to open.""" """Reopen a rejected propose back to open."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -264,7 +333,7 @@ def reopen_propose(
if propose_status != "rejected": if propose_status != "rejected":
raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened") raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened")
check_permission(db, current_user.id, project_id, "propose.reopen") check_permission(db, current_user.id, project.id, "propose.reopen")
propose.status = ProposeStatus.OPEN propose.status = ProposeStatus.OPEN
db.commit() db.commit()
@@ -272,4 +341,4 @@ def reopen_propose(
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id) log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
return propose return _serialize_propose(db, propose)

View File

@@ -2,7 +2,7 @@
import math import math
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
@@ -20,6 +20,19 @@ from app.services.dependency_check import check_task_deps
router = APIRouter(tags=["Tasks"]) router = APIRouter(tags=["Tasks"])
def _resolve_task(db: Session, identifier: str) -> Task:
"""Resolve a task by numeric id or task_code string.
Raises 404 if not found."""
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
except (ValueError, TypeError):
task = db.query(Task).filter(Task.task_code == identifier).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
# ---- State-machine: valid transitions (P5.1-P5.6) ---- # ---- State-machine: valid transitions (P5.1-P5.6) ----
VALID_TRANSITIONS: dict[str, set[str]] = { VALID_TRANSITIONS: dict[str, set[str]] = {
"pending": {"open", "closed"}, "pending": {"open", "closed"},
@@ -88,27 +101,100 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
return n return n
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
if project_id:
return project_id
if not project_code:
return None
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")
return project.id
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if milestone_id:
query = db.query(Milestone).filter(Milestone.id == milestone_id)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
elif milestone_code:
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
else:
return None
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
if task:
return task
except ValueError:
pass
return db.query(Task).filter(Task.task_code == identifier).first()
def _serialize_task(db: Session, task: Task) -> dict:
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
assignee = None
if task.assignee_id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
payload.update({
"code": task.task_code,
"type": task.task_type,
"project_code": project.project_code if project else None,
"milestone_code": milestone.milestone_code if milestone else None,
"taken_by": assignee.username if assignee else None,
"due_date": None,
})
return payload
# ---- CRUD ---- # ---- CRUD ----
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED) @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)): 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) requested_task_type = task_in.type or task_in.task_type
_validate_task_type_subtype(requested_task_type, task_in.task_subtype)
data = task_in.model_dump(exclude_unset=True) data = task_in.model_dump(exclude_unset=True)
if data.get("type") and not data.get("task_type"):
data["task_type"] = data.pop("type")
else:
data.pop("type", None)
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
if milestone:
data["milestone_id"] = milestone.id
data["project_id"] = milestone.project_id
data["reporter_id"] = data.get("reporter_id") or current_user.id data["reporter_id"] = data.get("reporter_id") or current_user.id
data["created_by_id"] = current_user.id data["created_by_id"] = current_user.id
if not data.get("project_id"): if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id is required") raise HTTPException(status_code=400, detail="project_id or project_code is required")
if not data.get("milestone_id"): if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id is required") raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev") check_project_role(db, current_user.id, data["project_id"], min_role="dev")
milestone = db.query(Milestone).filter( if not milestone:
Milestone.id == data["milestone_id"], milestone = db.query(Milestone).filter(
Milestone.project_id == data["project_id"], Milestone.id == data["milestone_id"],
).first() Milestone.project_id == data["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")
@@ -139,7 +225,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
db, db,
) )
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title}) log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
return db_task return _serialize_task(db, db_task)
@router.get("/tasks") @router.get("/tasks")
@@ -148,27 +234,51 @@ def list_tasks(
assignee_id: int = None, tag: str = None, assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc", sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50, page: int = 1, page_size: int = 50,
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
order_by: str = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
query = db.query(Task) query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id) resolved_project_id = _resolve_project_id(db, project_id, project)
if task_status: if resolved_project_id:
query = query.filter(Task.status == task_status) query = query.filter(Task.project_id == resolved_project_id)
if milestone:
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
query = query.filter(Task.milestone_id == milestone_obj.id)
effective_status = status_value or task_status
if effective_status:
query = query.filter(Task.status == effective_status)
if task_type: if task_type:
query = query.filter(Task.task_type == task_type) query = query.filter(Task.task_type == task_type)
if task_subtype: if task_subtype:
query = query.filter(Task.task_subtype == task_subtype) query = query.filter(Task.task_subtype == task_subtype)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id) effective_assignee_id = assignee_id
if taken_by == "null":
query = query.filter(Task.assignee_id.is_(None))
elif taken_by:
user = db.query(models.User).filter(models.User.username == taken_by).first()
if not user:
return {"items": [], "total": 0, "total_tasks": 0, "page": 1, "page_size": page_size, "total_pages": 1}
effective_assignee_id = user.id
if effective_assignee_id:
query = query.filter(Task.assignee_id == effective_assignee_id)
if tag: if tag:
query = query.filter(Task.tags.contains(tag)) query = query.filter(Task.tags.contains(tag))
effective_sort_by = order_by or sort_by
sort_fields = { sort_fields = {
"created_at": Task.created_at, "updated_at": Task.updated_at, "created": Task.created_at,
"priority": Task.priority, "title": Task.title, "created_at": Task.created_at,
"updated_at": Task.updated_at,
"priority": Task.priority,
"name": Task.title,
"title": Task.title,
} }
sort_col = sort_fields.get(sort_by, Task.created_at) sort_col = sort_fields.get(effective_sort_by, Task.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc()) query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
total = query.count() total = query.count()
@@ -177,7 +287,7 @@ def list_tasks(
total_pages = math.ceil(total / page_size) if total else 1 total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all() items = query.offset((page - 1) * page_size).limit(page_size).all()
return { return {
"items": [schemas.TaskResponse.model_validate(i) for i in items], "items": [_serialize_task(db, i) for i in items],
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,
"page": page, "page": page,
@@ -186,23 +296,52 @@ def list_tasks(
} }
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
def search_tasks_alias(
q: str,
project: str = None,
status: str = None,
db: Session = Depends(get_db),
):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
resolved_project_id = _resolve_project_id(db, None, project)
if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id)
if status:
query = query.filter(Task.status == status)
items = query.order_by(Task.created_at.desc()).limit(100).all()
return [_serialize_task(db, i) for i in items]
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse) @router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)): def get_task(task_id: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() task = _resolve_task(db, task_id)
if not task: return _serialize_task(db, task)
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse) @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)): def update_task(task_id: str, 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() task = _resolve_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# P5.7: status-based edit restrictions # P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status current_status = task.status.value if hasattr(task.status, 'value') else task.status
update_data = task_update.model_dump(exclude_unset=True) update_data = task_update.model_dump(exclude_unset=True)
if update_data.get("type") and not update_data.get("task_type"):
update_data["task_type"] = update_data.pop("type")
else:
update_data.pop("type", None)
if "taken_by" in update_data:
taken_by = update_data.pop("taken_by")
if taken_by in (None, "null", ""):
update_data["assignee_id"] = None
else:
assignee = db.query(models.User).filter(models.User.username == taken_by).first()
if not assignee:
raise HTTPException(status_code=404, detail="Assignee user not found")
update_data["assignee_id"] = assignee.id
# Fields that are always allowed regardless of status (non-body edits) # Fields that are always allowed regardless of status (non-body edits)
_always_allowed = {"status"} _always_allowed = {"status"}
@@ -268,14 +407,12 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep
from app.api.routers.milestone_actions import try_auto_complete_milestone from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, task, user_id=current_user.id) try_auto_complete_milestone(db, task, user_id=current_user.id)
return task return _serialize_task(db, task)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT) @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)): def delete_task(task_id: str, 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() task = _resolve_task(db, task_id)
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") 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}) log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title})
db.delete(task) db.delete(task)
@@ -286,24 +423,24 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
# ---- Transition ---- # ---- Transition ----
class TransitionBody(BaseModel): class TransitionBody(BaseModel):
status: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task( def transition_task(
task_id: int, task_id: str,
new_status: str,
bg: BackgroundTasks, bg: BackgroundTasks,
new_status: str | None = None,
body: TransitionBody = None, body: TransitionBody = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
new_status = new_status or (body.status if body else None)
valid_statuses = [s.value for s in TaskStatus] valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses: if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {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() task = _resolve_task(db, task_id)
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 old_status = task.status.value if hasattr(task.status, 'value') else task.status
# P5.1: enforce state-machine # P5.1: enforce state-machine
@@ -385,16 +522,47 @@ def transition_task(
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status}, {"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db) task.project_id, db)
return task return _serialize_task(db, task)
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse)
def take_task(
task_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
task = _find_task_by_id_or_code(db, task_id)
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")
if task.assignee_id and task.assignee_id != current_user.id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
assignee_name = assignee.username if assignee else str(task.assignee_id)
raise HTTPException(status_code=409, detail=f"Task is already taken by {assignee_name}")
task.assignee_id = current_user.id
db.commit()
db.refresh(task)
_notify_user(
db,
current_user.id,
"task.assigned",
f"Task {task.task_code or task.id} assigned to you",
f"'{task.title}' has been assigned to you.",
"task",
task.id,
)
return _serialize_task(db, task)
# ---- Assignment ---- # ---- Assignment ----
@router.post("/tasks/{task_id}/assign") @router.post("/tasks/{task_id}/assign")
def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)): def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() task = _resolve_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
user = db.query(models.User).filter(models.User.id == assignee_id).first() user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -410,10 +578,8 @@ def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)):
# ---- Tags ---- # ---- Tags ----
@router.post("/tasks/{task_id}/tags") @router.post("/tasks/{task_id}/tags")
def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)): def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() task = _resolve_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set() current = set(task.tags.split(",")) if task.tags else set()
current.add(tag.strip()) current.add(tag.strip())
current.discard("") current.discard("")
@@ -423,10 +589,8 @@ def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)):
@router.delete("/tasks/{task_id}/tags") @router.delete("/tasks/{task_id}/tags")
def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)): def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() task = _resolve_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
current = set(task.tags.split(",")) if task.tags else set() current = set(task.tags.split(",")) if task.tags else set()
current.discard(tag.strip()) current.discard(tag.strip())
current.discard("") current.discard("")
@@ -616,7 +780,7 @@ def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int =
total_pages = math.ceil(total / page_size) if total else 1 total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all() items = query.offset((page - 1) * page_size).limit(page_size).all()
return { return {
"items": [schemas.TaskResponse.model_validate(i) for i in items], "items": [_serialize_task(db, i) for i in items],
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,
"page": page, "page": page,

View File

@@ -102,31 +102,40 @@ def list_users(
return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all() return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
@router.get("/{user_id}", response_model=schemas.UserResponse) def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None:
"""Resolve a user by numeric id or username string."""
try:
uid = int(identifier)
return db.query(models.User).filter(models.User.id == uid).first()
except ValueError:
return db.query(models.User).filter(models.User.username == identifier).first()
@router.get("/{identifier}", response_model=schemas.UserResponse)
def get_user( def get_user(
user_id: int, identifier: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: models.User = Depends(require_admin), _: models.User = Depends(require_admin),
): ):
user = db.query(models.User).filter(models.User.id == user_id).first() user = _find_user_by_id_or_username(db, identifier)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return user return user
@router.patch("/{user_id}", response_model=schemas.UserResponse) @router.patch("/{identifier}", response_model=schemas.UserResponse)
def update_user( def update_user(
user_id: int, identifier: str,
payload: schemas.UserUpdate, payload: schemas.UserUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin), current_user: models.User = Depends(require_admin),
): ):
user = db.query(models.User).filter(models.User.id == user_id).first() user = _find_user_by_id_or_username(db, identifier)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if payload.email is not None and payload.email != user.email: if payload.email is not None and payload.email != user.email:
existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user_id).first() existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user.id).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="Email already exists") raise HTTPException(status_code=400, detail="Email already exists")
user.email = payload.email user.email = payload.email
@@ -153,17 +162,22 @@ def update_user(
return user return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user( def delete_user(
user_id: int, identifier: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(require_admin), current_user: models.User = Depends(require_admin),
): ):
user = db.query(models.User).filter(models.User.id == user_id).first() user = _find_user_by_id_or_username(db, identifier)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id: if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account") raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username == "acc-mgr":
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
try: try:
db.delete(user) db.delete(user)
db.commit() db.commit()
@@ -173,6 +187,65 @@ def delete_user(
return None return None
@router.post("/{identifier}/reset-apikey")
def reset_user_apikey(
identifier: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
"""Reset (regenerate) a user's API key.
Permission rules:
- user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key
"""
import secrets
from app.models.apikey import APIKey
target_user = _find_user_by_id_or_username(db, identifier)
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
is_self = current_user.id == target_user.id
can_reset_any = _has_global_permission(db, current_user, "user.reset-apikey")
can_reset_self = _has_global_permission(db, current_user, "user.reset-self-apikey")
if not (can_reset_any or (is_self and can_reset_self)):
raise HTTPException(status_code=403, detail="API key reset permission required")
# Find existing active API key for target user, or create one
existing_key = db.query(APIKey).filter(
APIKey.user_id == target_user.id,
APIKey.is_active == True,
).first()
new_key_value = secrets.token_hex(32)
if existing_key:
# Deactivate old key
existing_key.is_active = False
db.flush()
# Create new key
new_key = APIKey(
key=new_key_value,
name=f"{target_user.username}-key",
user_id=target_user.id,
is_active=True,
)
db.add(new_key)
db.commit()
db.refresh(new_key)
return {
"user_id": target_user.id,
"username": target_user.username,
"api_key": new_key_value,
"message": "API key has been reset. Please save this key — it will not be shown again.",
}
class WorkLogResponse(BaseModel): class WorkLogResponse(BaseModel):
id: int id: int
task_id: int task_id: int
@@ -186,13 +259,16 @@ class WorkLogResponse(BaseModel):
from_attributes = True from_attributes = True
@router.get("/{user_id}/worklogs", response_model=List[WorkLogResponse]) @router.get("/{identifier}/worklogs", response_model=List[WorkLogResponse])
def list_user_worklogs( def list_user_worklogs(
user_id: int, identifier: str,
limit: int = 50, limit: int = 50,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
if current_user.id != user_id and not current_user.is_admin: user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if current_user.id != user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()

View File

@@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [
("account.create", "Create HarborForge accounts", "account"), ("account.create", "Create HarborForge accounts", "account"),
# User management # User management
("user.manage", "Manage users", "admin"), ("user.manage", "Manage users", "admin"),
# API key management
("user.reset-self-apikey", "Reset own API key", "user"),
("user.reset-apikey", "Reset any user's API key", "admin"),
# Monitor # Monitor
("monitor.read", "View monitor", "monitor"), ("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"), ("monitor.manage", "Manage monitor", "monitor"),
@@ -165,6 +168,7 @@ _MGR_PERMISSIONS = {
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen", "propose.accept", "propose.reject", "propose.reopen",
"monitor.read", "monitor.read",
"user.reset-self-apikey",
} }
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose # dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
@@ -174,6 +178,7 @@ _DEV_PERMISSIONS = {
"milestone.read", "milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed", "task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read", "monitor.read",
"user.reset-self-apikey",
} }
_ACCOUNT_MANAGER_PERMISSIONS = { _ACCOUNT_MANAGER_PERMISSIONS = {
@@ -246,6 +251,43 @@ def init_admin_role(db: Session, admin_user: models.User) -> None:
logger.info("Default roles setup complete (admin, mgr, dev, guest)") logger.info("Default roles setup complete (admin, mgr, dev, guest)")
def init_acc_mgr_user(db: Session) -> models.User | None:
"""Create the built-in acc-mgr user if not exists.
This user:
- Has role 'account-manager' (can only create accounts)
- Cannot log in (no password, hashed_password=None)
- Cannot be deleted (enforced in delete endpoint)
- Is created automatically after wizard initialization
"""
username = "acc-mgr"
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
return existing
# Find account-manager role
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
if not acc_mgr_role:
logger.warning("account-manager role not found, skipping acc-mgr user creation")
return None
user = models.User(
username=username,
email="acc-mgr@harborforge.internal",
full_name="Account Manager",
hashed_password=None, # Cannot log in — no password
is_admin=False,
is_active=True,
role_id=acc_mgr_role.id,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id)
return user
def run_init(db: Session) -> None: def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume.""" """Main initialization entry point. Reads config from shared volume."""
config = load_config() config = load_config()
@@ -267,6 +309,9 @@ def run_init(db: Session) -> None:
if admin_user: if admin_user:
init_admin_role(db, admin_user) init_admin_role(db, admin_user)
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Default project # Default project
project_cfg = config.get("default_project") project_cfg = config.get("default_project")
if project_cfg and admin_user: if project_cfg and admin_user:

View File

@@ -26,6 +26,26 @@ def health_check():
def version(): def version():
return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"} return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"}
@app.get("/config/status", tags=["System"])
def config_status():
"""Check if HarborForge has been initialized (reads from config volume).
Frontend uses this instead of contacting the wizard directly."""
import os, json
config_dir = os.getenv("CONFIG_DIR", "/config")
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
config_path = os.path.join(config_dir, config_file)
if not os.path.exists(config_path):
return {"initialized": False}
try:
with open(config_path, "r") as f:
cfg = json.load(f)
return {
"initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
}
except Exception:
return {"initialized": False}
# 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.tasks import router as tasks_router from app.api.routers.tasks import router as tasks_router
@@ -39,6 +59,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
from app.api.routers.proposes import router as proposes_router from app.api.routers.proposes import router as proposes_router
from app.api.routers.milestone_actions import router as milestone_actions_router from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(tasks_router) app.include_router(tasks_router)
@@ -52,6 +73,7 @@ app.include_router(milestones_router)
app.include_router(roles_router) app.include_router(roles_router)
app.include_router(proposes_router) app.include_router(proposes_router)
app.include_router(milestone_actions_router) app.include_router(milestone_actions_router)
app.include_router(meetings_router)
# Auto schema migration for lightweight deployments # Auto schema migration for lightweight deployments

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.config import Base from app.core.config import Base
@@ -35,3 +35,19 @@ class Meeting(Base):
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())
participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan")
class MeetingParticipant(Base):
__tablename__ = "meeting_participants"
__table_args__ = (
UniqueConstraint("meeting_id", "user_id", name="uq_meeting_participant"),
)
id = Column(Integer, primary_key=True, index=True)
meeting_id = Column(Integer, ForeignKey("meetings.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
meeting = relationship("Meeting", back_populates="participants")

View File

@@ -44,9 +44,12 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: Optional[int] = None project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None reporter_id: Optional[int] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
type: Optional[TaskTypeEnum] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
positions: Optional[str] = None positions: Optional[str] = None
@@ -57,10 +60,12 @@ class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
task_type: Optional[TaskTypeEnum] = None task_type: Optional[TaskTypeEnum] = None
type: Optional[TaskTypeEnum] = None
task_subtype: Optional[str] = None task_subtype: Optional[str] = None
status: Optional[TaskStatusEnum] = None status: Optional[TaskStatusEnum] = None
priority: Optional[TaskPriorityEnum] = None priority: Optional[TaskPriorityEnum] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
taken_by: Optional[str] = None
tags: Optional[str] = None tags: Optional[str] = None
estimated_effort: Optional[int] = None estimated_effort: Optional[int] = None
# Resolution specific # Resolution specific
@@ -73,10 +78,16 @@ class TaskResponse(TaskBase):
id: int id: int
status: TaskStatusEnum status: TaskStatusEnum
task_code: Optional[str] = None task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int project_id: int
project_code: Optional[str] = None
milestone_id: int milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int reporter_id: int
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
taken_by: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
estimated_working_time: Optional[time] = None estimated_working_time: Optional[time] = None
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
@@ -197,6 +208,8 @@ class ProjectMemberCreate(ProjectMemberBase):
class ProjectMemberResponse(BaseModel): class ProjectMemberResponse(BaseModel):
id: int id: int
user_id: int user_id: int
username: Optional[str] = None
full_name: Optional[str] = None
project_id: int project_id: int
role: str = "dev" role: str = "dev"
@@ -240,6 +253,7 @@ class MilestoneUpdate(BaseModel):
class MilestoneResponse(MilestoneBase): class MilestoneResponse(MilestoneBase):
id: int id: int
milestone_code: Optional[str] = None
project_id: int project_id: int
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
@@ -278,6 +292,7 @@ class ProposeResponse(ProposeBase):
status: ProposeStatusEnum status: ProposeStatusEnum
project_id: int project_id: int
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
created_by_username: Optional[str] = None
feat_task_id: Optional[str] = None feat_task_id: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

408
cli.py
View File

@@ -1,408 +0,0 @@
#!/usr/bin/env python3
"""HarborForge CLI - 简易命令行工具"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000")
TOKEN = os.environ.get("HARBORFORGE_TOKEN", "")
STATUS_ICON = {
"open": "🟢",
"pending": "🟡",
"freeze": "🧊",
"undergoing": "🔵",
"completed": "",
"closed": "",
}
TYPE_ICON = {
"resolution": "⚖️",
"story": "📖",
"test": "🧪",
"issue": "📌",
"maintenance": "🛠️",
"research": "🔬",
"review": "🧐",
}
def _request(method, path, data=None):
url = f"{BASE_URL}{path}"
headers = {"Content-Type": "application/json"}
if TOKEN:
headers["Authorization"] = f"Bearer {TOKEN}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as resp:
if resp.status == 204:
return None
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
def cmd_login(args):
data = urllib.parse.urlencode({"username": args.username, "password": args.password}).encode()
req = urllib.request.Request(f"{BASE_URL}/auth/token", data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
print(f"Token: {result['access_token']}")
print(f"\nExport it:\nexport HARBORFORGE_TOKEN={result['access_token']}")
except urllib.error.HTTPError as e:
print(f"Login failed: {e.read().decode()}", file=sys.stderr)
sys.exit(1)
def cmd_tasks(args):
params = []
if args.project:
params.append(f"project_id={args.project}")
if args.type:
params.append(f"task_type={args.type}")
if args.status:
params.append(f"task_status={args.status}")
qs = f"?{'&'.join(params)}" if params else ""
result = _request("GET", f"/tasks{qs}")
items = result.get("items", result if isinstance(result, list) else [])
for task in items:
status_icon = STATUS_ICON.get(task["status"], "")
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
def cmd_task_create(args):
data = {
"title": args.title,
"project_id": args.project,
"milestone_id": args.milestone,
"reporter_id": args.reporter,
"task_type": args.type,
"priority": args.priority or "medium",
}
if args.description:
data["description"] = args.description
if args.assignee:
data["assignee_id"] = args.assignee
if args.subtype:
data["task_subtype"] = args.subtype
if args.type == "resolution":
if args.summary:
data["resolution_summary"] = args.summary
if args.positions:
data["positions"] = args.positions
if args.pending:
data["pending_matters"] = args.pending
result = _request("POST", "/tasks", data)
print(f"Created task #{result['id']}: {result['title']}")
def cmd_projects(args):
projects = _request("GET", "/projects")
for project in projects:
print(f" #{project['id']} {project['name']} - {project.get('description', '')}")
def cmd_users(args):
users = _request("GET", "/users")
for user in users:
role = "👑" if user["is_admin"] else "👤"
print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})")
def cmd_version(args):
result = _request("GET", "/version")
print(f"{result['name']} v{result['version']}")
def cmd_health(args):
result = _request("GET", "/health")
print(f"Status: {result['status']}")
def cmd_search(args):
params = [f"q={urllib.parse.quote(args.query)}"]
if args.project:
params.append(f"project_id={args.project}")
result = _request("GET", f"/search/tasks?{'&'.join(params)}")
items = result.get("items", result if isinstance(result, list) else [])
if not items:
print(" No results found.")
return
for task in items:
status_icon = STATUS_ICON.get(task["status"], "")
type_icon = TYPE_ICON.get(task.get("task_type"), "📌")
print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}")
def cmd_transition(args):
body = {}
if args.comment:
body["comment"] = args.comment
result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}", body or None)
print(f"Task #{result['id']} transitioned to: {result['status']}")
# ── Propose commands ──────────────────────────────────────────────
def cmd_proposes(args):
if not args.project:
print("Error: --project is required for proposes", file=sys.stderr)
sys.exit(1)
result = _request("GET", f"/projects/{args.project}/proposes")
items = result if isinstance(result, list) else result.get("items", [])
if not items:
print(" No proposes found.")
return
for p in items:
status_icon = STATUS_ICON.get(p["status"], "")
feat = f" → task {p['feat_task_id']}" if p.get("feat_task_id") else ""
print(f" {status_icon} 💡 {p['propose_code']} {p['title']}{feat}")
def cmd_propose_create(args):
data = {"title": args.title}
if args.description:
data["description"] = args.description
result = _request("POST", f"/projects/{args.project}/proposes", data)
print(f"Created propose {result['propose_code']}: {result['title']}")
def cmd_propose_accept(args):
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/accept?milestone_id={args.milestone}")
print(f"Propose #{args.propose_id} accepted → task {result.get('feat_task_id', '?')}")
def cmd_propose_reject(args):
data = {}
if args.reason:
data["reason"] = args.reason
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reject", data or None)
print(f"Propose #{args.propose_id} rejected")
def cmd_propose_reopen(args):
result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reopen")
print(f"Propose #{args.propose_id} reopened")
def cmd_stats(args):
params = f"?project_id={args.project}" if args.project else ""
stats = _request("GET", f"/dashboard/stats{params}")
print(f"Total: {stats['total_tasks']}")
print("By status:")
for status_name, count in stats["by_status"].items():
if count > 0:
print(f" {status_name}: {count}")
print("By type:")
for task_type, count in stats["by_type"].items():
if count > 0:
print(f" {task_type}: {count}")
def cmd_milestones(args):
params = []
if args.project:
params.append(f"project_id={args.project}")
if args.status:
params.append(f"status={args.status}")
qs = f"?{'&'.join(params)}" if params else ""
milestones = _request("GET", f"/milestones{qs}")
if not milestones:
print(" No milestones found.")
return
for milestone in milestones:
status_icon = STATUS_ICON.get(milestone["status"], "")
due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else ""
print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}")
def cmd_milestone_progress(args):
result = _request("GET", f"/milestones/{args.milestone_id}/progress")
bar_len = 20
filled = int(bar_len * result["progress_pct"] / 100)
bar = "" * filled + "" * (bar_len - filled)
print(f" {result['title']}")
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})")
def cmd_notifications(args):
params = []
if args.unread:
params.append("unread_only=true")
qs = f"?{'&'.join(params)}" if params else ""
notifications = _request("GET", f"/notifications{qs}")
if not notifications:
print(" No notifications.")
return
for notification in notifications:
icon = "🔴" if not notification["is_read"] else ""
print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}")
def cmd_overdue(args):
print("Overdue tasks are not supported by the current milestone-based task schema.")
def cmd_log_time(args):
from datetime import datetime
data = {
"task_id": args.task_id,
"user_id": args.user_id,
"hours": args.hours,
"logged_date": datetime.utcnow().isoformat(),
}
if args.desc:
data["description"] = args.desc
result = _request("POST", "/worklogs", data)
print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})")
def cmd_worklogs(args):
logs = _request("GET", f"/tasks/{args.task_id}/worklogs")
for log in logs:
desc = f" - {log['description']}" if log.get("description") else ""
print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}")
summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary")
print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)")
def main():
parser = argparse.ArgumentParser(description="HarborForge CLI")
sub = parser.add_subparsers(dest="command")
p_login = sub.add_parser("login", help="Login and get token")
p_login.add_argument("username")
p_login.add_argument("password")
p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks")
p_tasks.add_argument("--project", "-p", type=int)
p_tasks.add_argument("--type", "-t", choices=["story", "test", "resolution", "issue", "maintenance", "research", "review"])
p_tasks.add_argument("--status", "-s", choices=["open", "pending", "undergoing", "completed", "closed"])
p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task")
p_create.add_argument("title")
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("--type", "-t", default="issue", choices=["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("--description", "-d")
p_create.add_argument("--assignee", "-a", type=int)
p_create.add_argument("--summary")
p_create.add_argument("--positions")
p_create.add_argument("--pending")
sub.add_parser("projects", help="List projects")
sub.add_parser("users", help="List users")
sub.add_parser("version", help="Show version")
sub.add_parser("health", help="Health check")
p_search = sub.add_parser("search", help="Search tasks")
p_search.add_argument("query")
p_search.add_argument("--project", "-p", type=int)
p_trans = sub.add_parser("transition", help="Transition task status")
p_trans.add_argument("task_id", type=int)
p_trans.add_argument("status", choices=["open", "pending", "undergoing", "completed", "closed"])
p_trans.add_argument("--comment", "-c", help="Comment (required for undergoing→completed)")
p_stats = sub.add_parser("stats", help="Dashboard stats")
p_stats.add_argument("--project", "-p", type=int)
p_ms = sub.add_parser("milestones", help="List milestones")
p_ms.add_argument("--project", "-p", type=int)
p_ms.add_argument("--status", "-s", choices=["open", "freeze", "undergoing", "completed", "closed"])
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
p_msp.add_argument("milestone_id", type=int)
p_notif = sub.add_parser("notifications", help="List notifications for current token user")
p_notif.add_argument("--unread", action="store_true")
p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status")
p_overdue.add_argument("--project", "-p", type=int)
p_logtime = sub.add_parser("log-time", help="Log time on a task")
p_logtime.add_argument("task_id", type=int)
p_logtime.add_argument("user_id", type=int)
p_logtime.add_argument("hours", type=float)
p_logtime.add_argument("--desc", "-d", type=str)
p_worklogs = sub.add_parser("worklogs", help="List work logs for a task")
p_worklogs.add_argument("task_id", type=int)
# ── Propose subcommands ──
p_proposes = sub.add_parser("proposes", help="List proposes for a project")
p_proposes.add_argument("--project", "-p", type=int, required=True)
p_pc = sub.add_parser("propose-create", help="Create a propose")
p_pc.add_argument("title")
p_pc.add_argument("--project", "-p", type=int, required=True)
p_pc.add_argument("--description", "-d")
p_pa = sub.add_parser("propose-accept", help="Accept a propose into a milestone")
p_pa.add_argument("propose_id", type=int)
p_pa.add_argument("--project", "-p", type=int, required=True)
p_pa.add_argument("--milestone", "-m", type=int, required=True)
p_pr = sub.add_parser("propose-reject", help="Reject a propose")
p_pr.add_argument("propose_id", type=int)
p_pr.add_argument("--project", "-p", type=int, required=True)
p_pr.add_argument("--reason", "-r")
p_pro = sub.add_parser("propose-reopen", help="Reopen a rejected propose")
p_pro.add_argument("propose_id", type=int)
p_pro.add_argument("--project", "-p", type=int, required=True)
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
cmds = {
"login": cmd_login,
"tasks": cmd_tasks,
"issues": cmd_tasks,
"create-task": cmd_task_create,
"create-issue": cmd_task_create,
"projects": cmd_projects,
"users": cmd_users,
"version": cmd_version,
"health": cmd_health,
"search": cmd_search,
"transition": cmd_transition,
"stats": cmd_stats,
"milestones": cmd_milestones,
"milestone-progress": cmd_milestone_progress,
"notifications": cmd_notifications,
"overdue": cmd_overdue,
"log-time": cmd_log_time,
"worklogs": cmd_worklogs,
"proposes": cmd_proposes,
"propose-create": cmd_propose_create,
"propose-accept": cmd_propose_accept,
"propose-reject": cmd_propose_reject,
"propose-reopen": cmd_propose_reopen,
}
cmds[args.command](args)
if __name__ == "__main__":
main()

View File

@@ -5,9 +5,10 @@
## 当前架构 ## 当前架构
- HarborForge Monitor Backend 提供服务器注册与遥测接收接口 - HarborForge Monitor Backend 提供服务器注册与遥测接收接口
- OpenClaw Gateway 加载 `harborforge-monitor` 插件 - OpenClaw Gateway 加载 `harbor-forge` 插件
- 插件在 `gateway_start` 时启动 sidecar (`server/telemetry.mjs`) - sidecar (`server/telemetry.mjs`) 已移除
- sidecar 通过 **HTTP + X-API-Key** 向 Backend 上报遥测 - 插件通过 Gateway/runtime 路径直接提供 OpenClaw 元数据
- Monitor 可选通过本地 `monitor_port` 桥接读取补充信息
## 当前后端接口 ## 当前后端接口
@@ -39,7 +40,7 @@
## 数据语义 ## 数据语义
- `openclaw_version`: 远程服务器上的 OpenClaw 版本 - `openclaw_version`: 远程服务器上的 OpenClaw 版本
- `plugin_version`: 远程服务器上的 harborforge-monitor 插件版本 - `plugin_version`: 远程服务器上的 `harbor-forge` 插件版本
## 已废弃内容 ## 已废弃内容
@@ -67,10 +68,10 @@ Monitor 管理页应提供:
1. 管理员在 Monitor 中注册服务器 1. 管理员在 Monitor 中注册服务器
2. 管理员为服务器生成 API Key 2. 管理员为服务器生成 API Key
3. 将 API Key 写入 `~/.openclaw/openclaw.json` 3. 将 API Key 写入 `~/.openclaw/openclaw.json`
4. 重启 OpenClaw Gateway 4. 如需本地桥接补充信息,配置 `monitor_port`
5. 插件启动 sidecar 5. 重启 OpenClaw Gateway
6. sidecar 定时向 `/monitor/server/heartbeat-v2` 上报 6. 插件直接参与遥测链路;若本地桥接可达,则额外提供 OpenClaw 补充元数据
## 备注 ## 备注
当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口运行时逻辑。 当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口、WebSocket 方案或 sidecar 运行时逻辑。

View File

@@ -38,13 +38,14 @@
## 语义 ## 语义
- `openclaw_version`: 远程主机上的 OpenClaw 版本 - `openclaw_version`: 远程主机上的 OpenClaw 版本
- `plugin_version`: harborforge-monitor 插件版本 - `plugin_version`: `harbor-forge` 插件版本
## 插件生命周期 ## 插件生命周期
- 插件注册到 Gateway - 插件注册名为 `harbor-forge`
- `gateway_start` 启动 `server/telemetry.mjs` - 不再启动独立 `server/telemetry.mjs` sidecar
- `gateway_stop` 停止 sidecar - 插件直接通过 Gateway/runtime 路径暴露 OpenClaw 元数据
- 如配置了 `monitor_port`,插件还可通过本地桥接与 HarborForge.Monitor 交互
## 配置位置 ## 配置位置
@@ -54,13 +55,14 @@
{ {
"plugins": { "plugins": {
"entries": { "entries": {
"harborforge-monitor": { "harbor-forge": {
"enabled": true, "enabled": true,
"config": { "config": {
"enabled": true, "enabled": true,
"backendUrl": "http://127.0.0.1:8000", "backendUrl": "http://127.0.0.1:8000",
"identifier": "vps.t1", "identifier": "vps.t1",
"apiKey": "your-api-key" "apiKey": "your-api-key",
"monitor_port": 9100
} }
} }
} }