Compare commits
39 Commits
c1288b5fa9
...
755e4a80f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 755e4a80f9 | |||
| 7b2ac29f2c | |||
| 50f5e360e4 | |||
| d1f9129922 | |||
| 2e14077668 | |||
| 4a32ed921a | |||
| dac2de62f6 | |||
| 9254723f2c | |||
| afd769bc12 | |||
| 818dbf12b9 | |||
| c695ef903f | |||
| ace0707394 | |||
| 74177915df | |||
| 2f659e1430 | |||
| 1eb90cd61c | |||
| d5bf47f4fc | |||
| e5775bb9c8 | |||
| 6b3e42195d | |||
| 9f9aad8ce0 | |||
| 5f47a17794 | |||
| 863c79ef3e | |||
| c81654739c | |||
| d5402f3a70 | |||
| ff4baf6113 | |||
| 5b8f84d87d | |||
| c0ec70c64f | |||
| 74e054c51e | |||
| 9fb13f4906 | |||
| 464bccafd8 | |||
| d299428d35 | |||
| 95a4702e1e | |||
| 7218aabc59 | |||
| 7fe0a72549 | |||
| a21026ac09 | |||
| 3cf2b1bc49 | |||
| a56faacc4c | |||
| 622112c02f | |||
| 26ee18a4a4 | |||
| 6d58ee779c |
@@ -5,6 +5,7 @@ WORKDIR /app
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
72
app/api/rbac.py
Normal file
72
app/api/rbac.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Role-based access control helpers - using configurable permissions."""
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role, Permission, RolePermission
|
||||
from app.models import models
|
||||
|
||||
|
||||
def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
|
||||
"""Get user's role in a project."""
|
||||
member = db.query(models.ProjectMember).filter(
|
||||
models.ProjectMember.user_id == user_id,
|
||||
models.ProjectMember.project_id == project_id,
|
||||
).first()
|
||||
|
||||
if member and member.role_id:
|
||||
return db.query(Role).filter(Role.id == member.role_id).first()
|
||||
|
||||
# Check global admin
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if user and user.is_admin:
|
||||
# Return global admin role
|
||||
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_permission(db: Session, user_id: int, project_id: int, permission: str) -> bool:
|
||||
"""Check if user has a specific permission in a project."""
|
||||
role = get_user_role(db, user_id, project_id)
|
||||
|
||||
if not role:
|
||||
return False
|
||||
|
||||
# Check if role has the permission
|
||||
perm = db.query(Permission).filter(Permission.name == permission).first()
|
||||
if not perm:
|
||||
return False
|
||||
|
||||
role_perm = db.query(RolePermission).filter(
|
||||
RolePermission.role_id == role.id,
|
||||
RolePermission.permission_id == perm.id
|
||||
).first()
|
||||
|
||||
return role_perm is not None
|
||||
|
||||
|
||||
def check_permission(db: Session, user_id: int, project_id: int, permission: str):
|
||||
"""Raise 403 if user doesn't have the permission."""
|
||||
if not has_permission(db, user_id, project_id, permission):
|
||||
role = get_user_role(db, user_id, project_id)
|
||||
role_name = role.name if role else "none"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission '{permission}' required. Your role: {role_name}"
|
||||
)
|
||||
|
||||
|
||||
# Keep old function for backward compatibility (deprecated)
|
||||
def check_project_role(db: Session, user_id: int, project_id: int, min_role: str = "viewer"):
|
||||
"""Legacy function - maps old role names to new permission system."""
|
||||
# Map old roles to permissions
|
||||
role_to_perm = {
|
||||
"admin": "project.edit",
|
||||
"mgr": "milestone.create",
|
||||
"dev": "issue.create",
|
||||
"ops": "issue.view",
|
||||
"viewer": "project.view",
|
||||
}
|
||||
|
||||
perm = role_to_perm.get(min_role, "project.view")
|
||||
check_permission(db, user_id, project_id, perm)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Comments router."""
|
||||
"""Comments router with RBAC and notifications."""
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -6,16 +6,47 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import get_db
|
||||
from app.models import models
|
||||
from app.schemas import schemas
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.rbac import check_project_role
|
||||
from app.models.notification import Notification as NotificationModel
|
||||
|
||||
router = APIRouter(tags=["Comments"])
|
||||
|
||||
|
||||
def _notify_if_needed(db, issue_id, user_ids, ntype, title):
|
||||
"""Helper to notify multiple users."""
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
return
|
||||
for uid in set(user_ids):
|
||||
if uid:
|
||||
n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id)
|
||||
db.add(n)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)):
|
||||
def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
# Get project_id from issue first
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
|
||||
|
||||
db_comment = models.Comment(**comment.model_dump())
|
||||
db.add(db_comment)
|
||||
db.commit()
|
||||
db.refresh(db_comment)
|
||||
|
||||
# Notify reporter and assignee (but not the commenter themselves)
|
||||
notify_users = []
|
||||
if issue.reporter_id != current_user.id:
|
||||
notify_users.append(issue.reporter_id)
|
||||
if issue.assignee_id and issue.assignee_id != current_user.id:
|
||||
notify_users.append(issue.assignee_id)
|
||||
if notify_users:
|
||||
_notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}")
|
||||
|
||||
return db_comment
|
||||
|
||||
|
||||
@@ -25,10 +56,14 @@ def list_comments(issue_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse)
|
||||
def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)):
|
||||
def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="viewer")
|
||||
for field, value in comment_update.model_dump(exclude_unset=True).items():
|
||||
setattr(comment, field, value)
|
||||
db.commit()
|
||||
@@ -37,10 +72,15 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_comment(comment_id: int, db: Session = Depends(get_db)):
|
||||
def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
# Get issue to check project role
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
||||
db.delete(comment)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@@ -11,9 +11,41 @@ from app.models import models
|
||||
from app.schemas import schemas
|
||||
from app.services.webhook import fire_webhooks_sync
|
||||
from app.models.notification import Notification as NotificationModel
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.rbac import check_project_role
|
||||
from app.services.activity import log_activity
|
||||
|
||||
router = APIRouter(tags=["Issues"])
|
||||
|
||||
# ---- Type / Subtype validation ----
|
||||
ISSUE_SUBTYPE_MAP = {
|
||||
'meeting': {'conference', 'handover', 'recap'},
|
||||
'support': {'access', 'information'},
|
||||
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
|
||||
'maintenance': {'deploy', 'release'},
|
||||
'review': {'code_review', 'decision_review', 'function_review'},
|
||||
'story': {'feature', 'improvement', 'refactor'},
|
||||
'test': {'regression', 'security', 'smoke', 'stress'},
|
||||
'research': set(),
|
||||
'task': {'defect'},
|
||||
'resolution': set(),
|
||||
}
|
||||
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
|
||||
|
||||
|
||||
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
|
||||
if issue_type is None:
|
||||
raise HTTPException(status_code=400, detail='issue_type is required')
|
||||
if issue_type not in ALLOWED_ISSUE_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
|
||||
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
|
||||
if issue_subtype:
|
||||
if issue_subtype not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
|
||||
else:
|
||||
if require_subtype and allowed:
|
||||
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
|
||||
|
||||
|
||||
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
|
||||
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
|
||||
@@ -26,7 +58,8 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
|
||||
# ---- CRUD ----
|
||||
|
||||
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
||||
db_issue = models.Issue(**issue.model_dump())
|
||||
db.add(db_issue)
|
||||
db.commit()
|
||||
@@ -35,12 +68,13 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session =
|
||||
bg.add_task(fire_webhooks_sync, event,
|
||||
{"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
|
||||
db_issue.project_id, db)
|
||||
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
|
||||
return db_issue
|
||||
|
||||
|
||||
@router.get("/issues")
|
||||
def list_issues(
|
||||
project_id: int = None, issue_status: str = None, issue_type: str = None,
|
||||
project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
|
||||
assignee_id: int = None, tag: str = None,
|
||||
sort_by: str = "created_at", sort_order: str = "desc",
|
||||
page: int = 1, page_size: int = 50,
|
||||
@@ -54,6 +88,8 @@ def list_issues(
|
||||
query = query.filter(models.Issue.status == issue_status)
|
||||
if issue_type:
|
||||
query = query.filter(models.Issue.issue_type == issue_type)
|
||||
if issue_subtype:
|
||||
query = query.filter(models.Issue.issue_subtype == issue_subtype)
|
||||
if assignee_id:
|
||||
query = query.filter(models.Issue.assignee_id == assignee_id)
|
||||
if tag:
|
||||
@@ -93,15 +129,27 @@ def get_issue(issue_id: int, db: Session = Depends(get_db)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
return issue
|
||||
|
||||
|
||||
@router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse)
|
||||
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db)):
|
||||
def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if issue:
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
for field, value in issue_update.model_dump(exclude_unset=True).items():
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
@@ -109,10 +157,18 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session =
|
||||
|
||||
|
||||
@router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_issue(issue_id: int, db: Session = Depends(get_db)):
|
||||
def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if issue:
|
||||
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
|
||||
db.delete(issue)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -128,6 +184,11 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
old_status = issue.status
|
||||
issue.status = new_status
|
||||
db.commit()
|
||||
@@ -146,6 +207,11 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db))
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -200,6 +266,11 @@ def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
current = set(issue.tags.split(",")) if issue.tags else set()
|
||||
current.add(tag.strip())
|
||||
current.discard("")
|
||||
@@ -213,6 +284,11 @@ def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
update_data = issue_update.model_dump(exclude_unset=True)
|
||||
if "issue_type" in update_data or "issue_subtype" in update_data:
|
||||
new_type = update_data.get("issue_type", issue.issue_type)
|
||||
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
|
||||
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
|
||||
current = set(issue.tags.split(",")) if issue.tags else set()
|
||||
current.discard(tag.strip())
|
||||
current.discard("")
|
||||
|
||||
68
app/api/routers/milestones.py
Normal file
68
app/api/routers/milestones.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Milestones API router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.rbac import check_project_role
|
||||
from app.models import models
|
||||
from app.models.milestone import Milestone
|
||||
from app.schemas import schemas
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
|
||||
|
||||
|
||||
@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)):
|
||||
"""List all milestones for a project."""
|
||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
||||
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
|
||||
return milestones
|
||||
|
||||
|
||||
@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)):
|
||||
"""Create a new milestone for a project."""
|
||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||
db_milestone = Milestone(project_id=project_id, **milestone.model_dump())
|
||||
db.add(db_milestone)
|
||||
db.commit()
|
||||
db.refresh(db_milestone)
|
||||
return db_milestone
|
||||
|
||||
|
||||
@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)):
|
||||
"""Get a milestone by ID."""
|
||||
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
||||
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
||||
if not milestone:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
return milestone
|
||||
|
||||
|
||||
@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)):
|
||||
"""Update a milestone."""
|
||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
||||
if not db_milestone:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
for key, value in milestone.model_dump(exclude_unset=True).items():
|
||||
setattr(db_milestone, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_milestone)
|
||||
return db_milestone
|
||||
|
||||
|
||||
@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)):
|
||||
"""Delete a milestone."""
|
||||
check_project_role(db, current_user.id, project_id, min_role="admin")
|
||||
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
|
||||
if not db_milestone:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found")
|
||||
db.delete(db_milestone)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import func as sqlfunc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
from app.models.activity import ActivityLog
|
||||
@@ -184,38 +185,40 @@ class NotificationResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=List[NotificationResponse], tags=["Notifications"])
|
||||
def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)):
|
||||
query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id)
|
||||
def list_notifications(unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
query = db.query(NotificationModel).filter(NotificationModel.user_id == current_user.id)
|
||||
if unread_only:
|
||||
query = query.filter(NotificationModel.is_read == False)
|
||||
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/notifications/count", tags=["Notifications"])
|
||||
def notification_count(user_id: int, db: Session = Depends(get_db)):
|
||||
def notification_count(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
count = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id, NotificationModel.is_read == False
|
||||
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
|
||||
).count()
|
||||
return {"user_id": user_id, "unread": count}
|
||||
return {"user_id": current_user.id, "count": count, "unread": count}
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
|
||||
def mark_read(notification_id: int, db: Session = Depends(get_db)):
|
||||
def mark_read(notification_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
if n.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
n.is_read = True
|
||||
db.commit()
|
||||
return {"status": "read"}
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
def mark_all_read(user_id: int, db: Session = Depends(get_db)):
|
||||
def mark_all_read(db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id, NotificationModel.is_read == False
|
||||
NotificationModel.user_id == current_user.id, NotificationModel.is_read == False
|
||||
).update({"is_read": True})
|
||||
db.commit()
|
||||
return {"status": "all_read"}
|
||||
return {"status": "all_read", "user_id": current_user.id}
|
||||
|
||||
|
||||
# ============ Work Logs ============
|
||||
@@ -291,11 +294,11 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||
issues = query.all()
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["id", "title", "type", "status", "priority", "project_id",
|
||||
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
|
||||
"reporter_id", "assignee_id", "milestone_id", "due_date",
|
||||
"tags", "created_at", "updated_at"])
|
||||
for i in issues:
|
||||
writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id,
|
||||
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
|
||||
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
|
||||
i.tags, i.created_at, i.updated_at])
|
||||
output.seek(0)
|
||||
|
||||
287
app/api/routers/monitor.py
Normal file
287
app/api/routers/monitor.py
Normal file
@@ -0,0 +1,287 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import uuid
|
||||
from typing import List, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db, SessionLocal
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.models import models
|
||||
from app.models.monitor import (
|
||||
ProviderAccount,
|
||||
MonitoredServer,
|
||||
ServerState,
|
||||
ServerChallenge,
|
||||
ServerHandshakeNonce,
|
||||
)
|
||||
from app.services.monitoring import (
|
||||
get_issue_stats_cached,
|
||||
get_provider_usage_view,
|
||||
get_server_states_view,
|
||||
test_provider_connection,
|
||||
)
|
||||
from app.services.crypto_box import get_public_key_info, decrypt_payload_b64, ts_within
|
||||
|
||||
router = APIRouter(prefix='/monitor', tags=['Monitor'])
|
||||
SUPPORTED_PROVIDERS = {'anthropic', 'openai', 'minimax', 'kimi', 'qwen'}
|
||||
ACTIVE_WS: Dict[int, WebSocket] = {}
|
||||
|
||||
|
||||
class ProviderAccountCreate(BaseModel):
|
||||
provider: str
|
||||
label: str
|
||||
credential: str
|
||||
|
||||
|
||||
class ProviderTestRequest(BaseModel):
|
||||
provider: str
|
||||
credential: str
|
||||
|
||||
|
||||
class MonitoredServerCreate(BaseModel):
|
||||
identifier: str
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class ChallengeResponse(BaseModel):
|
||||
identifier: str
|
||||
challenge_uuid: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail='Admin required')
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get('/public/server-public-key')
|
||||
def monitor_public_key():
|
||||
return get_public_key_info()
|
||||
|
||||
|
||||
@router.get('/public/overview')
|
||||
def public_overview(db: Session = Depends(get_db)):
|
||||
return {
|
||||
'issues': get_issue_stats_cached(db, ttl_seconds=1800),
|
||||
'providers': get_provider_usage_view(db),
|
||||
'servers': get_server_states_view(db, offline_after_minutes=7),
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/admin/providers/accounts')
|
||||
def list_provider_accounts(db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
|
||||
accounts = db.query(ProviderAccount).order_by(ProviderAccount.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
'id': a.id,
|
||||
'provider': a.provider,
|
||||
'label': a.label,
|
||||
'is_enabled': a.is_enabled,
|
||||
'created_at': a.created_at,
|
||||
'credential_masked': '***' + (a.credential[-4:] if a.credential else ''),
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
|
||||
@router.post('/admin/providers/accounts', status_code=status.HTTP_201_CREATED)
|
||||
def create_provider_account(payload: ProviderAccountCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)):
|
||||
provider = payload.provider.lower().strip()
|
||||
if provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f'Unsupported provider: {provider}')
|
||||
obj = ProviderAccount(
|
||||
provider=provider,
|
||||
label=payload.label.strip(),
|
||||
credential=payload.credential.strip(),
|
||||
is_enabled=True,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return {'id': obj.id, 'provider': obj.provider, 'label': obj.label, 'is_enabled': obj.is_enabled}
|
||||
|
||||
|
||||
@router.post('/admin/providers/test')
|
||||
def test_provider(payload: ProviderTestRequest, _: models.User = Depends(require_admin)):
|
||||
ok, message = test_provider_connection(payload.provider.lower().strip(), payload.credential.strip())
|
||||
return {'ok': ok, 'message': message}
|
||||
|
||||
|
||||
@router.delete('/admin/providers/accounts/{account_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_provider_account(account_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
|
||||
obj = db.query(ProviderAccount).filter(ProviderAccount.id == account_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail='Provider account not found')
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/admin/servers')
|
||||
def list_servers(db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
|
||||
return get_server_states_view(db, offline_after_minutes=7)
|
||||
|
||||
|
||||
@router.post('/admin/servers', status_code=status.HTTP_201_CREATED)
|
||||
def add_server(payload: MonitoredServerCreate, db: Session = Depends(get_db), user: models.User = Depends(require_admin)):
|
||||
identifier = payload.identifier.strip()
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=400, detail='identifier required')
|
||||
exists = db.query(MonitoredServer).filter(MonitoredServer.identifier == identifier).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail='identifier already exists')
|
||||
obj = MonitoredServer(identifier=identifier, display_name=payload.display_name, is_enabled=True, created_by=user.id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return {'id': obj.id, 'identifier': obj.identifier, 'display_name': obj.display_name, 'is_enabled': obj.is_enabled}
|
||||
|
||||
|
||||
@router.post('/admin/servers/{server_id}/challenge', response_model=ChallengeResponse)
|
||||
def issue_server_challenge(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
|
||||
server = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail='Server not found')
|
||||
challenge_uuid = str(uuid.uuid4())
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
|
||||
ch = ServerChallenge(server_id=server_id, challenge_uuid=challenge_uuid, expires_at=expires_at)
|
||||
db.add(ch)
|
||||
db.commit()
|
||||
return ChallengeResponse(identifier=server.identifier, challenge_uuid=challenge_uuid, expires_at=expires_at.isoformat())
|
||||
|
||||
|
||||
@router.delete('/admin/servers/{server_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_server(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
|
||||
obj = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail='Server not found')
|
||||
state = db.query(ServerState).filter(ServerState.server_id == server_id).first()
|
||||
if state:
|
||||
db.delete(state)
|
||||
db.query(ServerChallenge).filter(ServerChallenge.server_id == server_id).delete()
|
||||
db.query(ServerHandshakeNonce).filter(ServerHandshakeNonce.server_id == server_id).delete()
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
class ServerHeartbeat(BaseModel):
|
||||
identifier: str
|
||||
openclaw_version: str | None = None
|
||||
agents: List[dict] = []
|
||||
cpu_pct: float | None = None
|
||||
mem_pct: float | None = None
|
||||
disk_pct: float | None = None
|
||||
swap_pct: float | None = None
|
||||
|
||||
|
||||
@router.post('/server/heartbeat')
|
||||
def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
|
||||
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == payload.identifier, MonitoredServer.is_enabled == True).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail='unknown server identifier')
|
||||
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
if not st:
|
||||
st = ServerState(server_id=server.id)
|
||||
db.add(st)
|
||||
st.openclaw_version = payload.openclaw_version
|
||||
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
|
||||
st.cpu_pct = payload.cpu_pct
|
||||
st.mem_pct = payload.mem_pct
|
||||
st.disk_pct = payload.disk_pct
|
||||
st.swap_pct = payload.swap_pct
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
|
||||
|
||||
|
||||
@router.websocket('/server/ws')
|
||||
async def server_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
server_id = None
|
||||
try:
|
||||
hello = await websocket.receive_json()
|
||||
|
||||
encrypted_payload = (hello.get('encrypted_payload') or '').strip()
|
||||
if encrypted_payload:
|
||||
data = decrypt_payload_b64(encrypted_payload)
|
||||
identifier = (data.get('identifier') or '').strip()
|
||||
challenge_uuid = (data.get('challenge_uuid') or '').strip()
|
||||
nonce = (data.get('nonce') or '').strip()
|
||||
ts = data.get('ts')
|
||||
if not ts_within(ts, max_minutes=10):
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
else:
|
||||
# backward compatible mode
|
||||
identifier = (hello.get('identifier') or '').strip()
|
||||
challenge_uuid = (hello.get('challenge_uuid') or '').strip()
|
||||
nonce = (hello.get('nonce') or '').strip()
|
||||
|
||||
if not identifier or not challenge_uuid or not nonce:
|
||||
await websocket.close(code=4400)
|
||||
return
|
||||
|
||||
server = db.query(MonitoredServer).filter(MonitoredServer.identifier == identifier, MonitoredServer.is_enabled == True).first()
|
||||
if not server:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
ch = db.query(ServerChallenge).filter(ServerChallenge.challenge_uuid == challenge_uuid, ServerChallenge.server_id == server.id).first()
|
||||
if not ch or ch.used_at is not None or ch.expires_at < datetime.now(timezone.utc):
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
nonce_used = db.query(ServerHandshakeNonce).filter(ServerHandshakeNonce.server_id == server.id, ServerHandshakeNonce.nonce == nonce).first()
|
||||
if nonce_used:
|
||||
await websocket.close(code=4409)
|
||||
return
|
||||
|
||||
db.add(ServerHandshakeNonce(server_id=server.id, nonce=nonce))
|
||||
ch.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
server_id = server.id
|
||||
ACTIVE_WS[server.id] = websocket
|
||||
await websocket.send_json({'ok': True, 'server_id': server.id, 'message': 'connected'})
|
||||
|
||||
while True:
|
||||
msg = await websocket.receive_json()
|
||||
event = msg.get('event')
|
||||
payload = msg.get('payload') or {}
|
||||
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
|
||||
if not st:
|
||||
st = ServerState(server_id=server.id)
|
||||
db.add(st)
|
||||
|
||||
if event == 'server.hello':
|
||||
st.openclaw_version = payload.get('openclaw_version')
|
||||
st.agents_json = json.dumps(payload.get('agents') or [], ensure_ascii=False)
|
||||
elif event in {'server.metrics', 'agent.status_changed'}:
|
||||
st.cpu_pct = payload.get('cpu_pct', st.cpu_pct)
|
||||
st.mem_pct = payload.get('mem_pct', st.mem_pct)
|
||||
st.disk_pct = payload.get('disk_pct', st.disk_pct)
|
||||
st.swap_pct = payload.get('swap_pct', st.swap_pct)
|
||||
if 'agents' in payload:
|
||||
st.agents_json = json.dumps(payload.get('agents') or [], ensure_ascii=False)
|
||||
|
||||
st.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await websocket.close(code=1011)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if server_id and ACTIVE_WS.get(server_id) is websocket:
|
||||
ACTIVE_WS.pop(server_id, None)
|
||||
db.close()
|
||||
@@ -1,21 +1,179 @@
|
||||
"""Projects router."""
|
||||
"""Projects router with RBAC."""
|
||||
import json
|
||||
import re
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role
|
||||
from app.schemas import schemas
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.rbac import check_project_role, check_permission
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||
|
||||
|
||||
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
|
||||
if not codes:
|
||||
return None
|
||||
# dedupe preserve order
|
||||
seen = set()
|
||||
ordered = []
|
||||
for c in codes:
|
||||
if c and c not in seen:
|
||||
ordered.append(c)
|
||||
seen.add(c)
|
||||
if self_code and self_code in seen:
|
||||
raise HTTPException(status_code=400, detail='Project cannot link to itself')
|
||||
existing = {p.project_code for p in db.query(models.Project).filter(models.Project.project_code.in_(ordered)).all()}
|
||||
missing = [c for c in ordered if c not in existing]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f'Unknown project codes: {", ".join(missing)}')
|
||||
return ordered
|
||||
|
||||
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
|
||||
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
|
||||
|
||||
|
||||
def _split_words(name: str):
|
||||
segments = WORD_SEGMENT_RE.findall(name or '')
|
||||
words = []
|
||||
for seg in segments:
|
||||
parts = CAMEL_RE.findall(seg)
|
||||
for part in parts:
|
||||
if part.isupper() and len(part) > 1:
|
||||
words.extend(list(part))
|
||||
else:
|
||||
words.append(part)
|
||||
return words
|
||||
|
||||
|
||||
def _code_exists(db, code: str) -> bool:
|
||||
return db.query(models.Project).filter(models.Project.project_code == code).first() is not None
|
||||
|
||||
|
||||
def _next_counter(db, prefix: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ''
|
||||
counter = db.query(models.ProjectCodeCounter).filter(models.ProjectCodeCounter.prefix == prefix).first()
|
||||
if not counter:
|
||||
counter = models.ProjectCodeCounter(prefix=prefix, next_value=0)
|
||||
db.add(counter)
|
||||
db.flush()
|
||||
value = counter.next_value
|
||||
counter.next_value += 1
|
||||
db.flush()
|
||||
return format(value, 'x').upper().zfill(width)
|
||||
|
||||
|
||||
def _generate_with_counter(db, prefix: str, width: int) -> str:
|
||||
if prefix.upper() == 'UN':
|
||||
prefix = 'UN'
|
||||
while True:
|
||||
suffix = _next_counter(db, prefix, width)
|
||||
code = (prefix + suffix).upper()
|
||||
if not _code_exists(db, code):
|
||||
return code
|
||||
|
||||
|
||||
def _generate_project_code(db, name: str) -> str:
|
||||
words = _split_words(name)
|
||||
if not words:
|
||||
return _generate_with_counter(db, 'UN', 4)
|
||||
|
||||
if len(words) == 1:
|
||||
letters = ''.join([c for c in words[0] if c.isalpha()]).upper()
|
||||
if not letters:
|
||||
return _generate_with_counter(db, 'UN', 4)
|
||||
if len(letters) >= 6:
|
||||
code = letters[:6]
|
||||
if _code_exists(db, code):
|
||||
raise HTTPException(status_code=400, detail='Project code collision')
|
||||
return code
|
||||
prefix = letters
|
||||
width = 6 - len(prefix)
|
||||
return _generate_with_counter(db, prefix, width)
|
||||
|
||||
total_letters = sum(len(w) for w in words)
|
||||
if len(words) > 6:
|
||||
code = ''.join([w[0] for w in words[:6]]).upper()
|
||||
if _code_exists(db, code):
|
||||
raise HTTPException(status_code=400, detail='Project code collision')
|
||||
return code
|
||||
|
||||
if total_letters < 6:
|
||||
prefix = ''.join(words).upper()
|
||||
width = 6 - len(prefix)
|
||||
return _generate_with_counter(db, prefix, width)
|
||||
|
||||
if total_letters == 6:
|
||||
code = ''.join(words).upper()
|
||||
if _code_exists(db, code):
|
||||
raise HTTPException(status_code=400, detail='Project code collision')
|
||||
return code
|
||||
|
||||
word_count = len(words)
|
||||
needed = 6 - word_count
|
||||
for idx in range(word_count - 1, -1, -1):
|
||||
extra_letters = list(words[idx][1:])
|
||||
if needed > len(extra_letters):
|
||||
continue
|
||||
indices = list(range(len(extra_letters)))
|
||||
def combos(start, depth, path):
|
||||
if depth == 0:
|
||||
yield path
|
||||
return
|
||||
for i in range(start, len(indices) - depth + 1):
|
||||
yield from combos(i + 1, depth - 1, path + [indices[i]])
|
||||
for combo in combos(0, needed, []):
|
||||
pieces = []
|
||||
for wi, w in enumerate(words):
|
||||
pieces.append(w[0])
|
||||
if wi == idx:
|
||||
pieces.extend([extra_letters[i] for i in combo])
|
||||
code = ''.join(pieces)[:6].upper()
|
||||
if not _code_exists(db, code):
|
||||
return code
|
||||
raise HTTPException(status_code=400, detail='Project code collision')
|
||||
|
||||
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
|
||||
db_project = models.Project(**project.model_dump())
|
||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
# Check if user is admin
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can create projects")
|
||||
# Auto-fill owner_name from owner_id
|
||||
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid owner_id: user not found")
|
||||
payload = project.model_dump()
|
||||
payload["owner_name"] = payload.get("owner_name") or user.username
|
||||
payload["project_code"] = _generate_project_code(db, project.name)
|
||||
|
||||
# Validate and serialize sub_projects
|
||||
sub_codes = payload.get("sub_projects")
|
||||
if sub_codes:
|
||||
payload["sub_projects"] = json.dumps(_validate_project_links(db, sub_codes, payload["project_code"]))
|
||||
else:
|
||||
payload["sub_projects"] = None
|
||||
|
||||
# Validate and serialize related_projects
|
||||
related_codes = payload.get("related_projects")
|
||||
if related_codes:
|
||||
payload["related_projects"] = json.dumps(_validate_project_links(db, related_codes, payload["project_code"]))
|
||||
else:
|
||||
payload["related_projects"] = None
|
||||
|
||||
db_project = models.Project(**payload)
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
# Auto-add creator as admin member
|
||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
||||
db_member = models.ProjectMember(project_id=db_project.id, user_id=project.owner_id, role_id=admin_role.id if admin_role else None)
|
||||
db.add(db_member)
|
||||
db.commit()
|
||||
return db_project
|
||||
|
||||
|
||||
@@ -33,11 +191,27 @@ def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=schemas.ProjectResponse)
|
||||
def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)):
|
||||
def update_project(
|
||||
project_id: int,
|
||||
project_update: schemas.ProjectUpdate,
|
||||
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 = db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
for field, value in project_update.model_dump(exclude_unset=True).items():
|
||||
update_data = project_update.model_dump(exclude_unset=True)
|
||||
update_data.pop("name", None)
|
||||
if "sub_projects" in update_data and update_data["sub_projects"]:
|
||||
update_data["sub_projects"] = json.dumps(update_data["sub_projects"])
|
||||
elif "sub_projects" in update_data:
|
||||
update_data["sub_projects"] = None
|
||||
if "related_projects" in update_data and update_data["related_projects"]:
|
||||
update_data["related_projects"] = json.dumps(update_data["related_projects"])
|
||||
elif "related_projects" in update_data:
|
||||
update_data["related_projects"] = None
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
@@ -45,10 +219,47 @@ def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: S
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||
def delete_project(
|
||||
project_id: int,
|
||||
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 = db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project_code = project.project_code
|
||||
|
||||
# Delete milestones and their issues
|
||||
from app.models.milestone import Milestone
|
||||
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
|
||||
for ms in milestones:
|
||||
# Delete issues under milestone
|
||||
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
|
||||
for issue in issues:
|
||||
db.delete(issue)
|
||||
db.delete(ms)
|
||||
|
||||
# Delete project members
|
||||
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
|
||||
for m in members:
|
||||
db.delete(m)
|
||||
|
||||
# Remove from other projects' sub_projects and related_projects
|
||||
import json
|
||||
all_projects = db.query(models.Project).all()
|
||||
for p in all_projects:
|
||||
if p.sub_projects and project_code in p.sub_projects:
|
||||
subs = json.loads(p.sub_projects) if p.sub_projects else []
|
||||
subs = [s for s in subs if s != project_code]
|
||||
p.sub_projects = json.dumps(subs) if subs else None
|
||||
|
||||
if p.related_projects and project_code in p.related_projects:
|
||||
related = json.loads(p.related_projects) if p.related_projects else []
|
||||
related = [r for r in related if r != project_code]
|
||||
p.related_projects = json.dumps(related) if related else None
|
||||
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -57,7 +268,13 @@ def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||
# ---- Members ----
|
||||
|
||||
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)):
|
||||
def add_project_member(
|
||||
project_id: int,
|
||||
member: schemas.ProjectMemberCreate,
|
||||
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 = db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
@@ -69,23 +286,68 @@ def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db:
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already a member")
|
||||
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role)
|
||||
# Convert role name to role_id
|
||||
role = db.query(Role).filter(Role.name == member.role).first()
|
||||
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.add(db_member)
|
||||
db.commit()
|
||||
db.refresh(db_member)
|
||||
return db_member
|
||||
role_name = "developer"
|
||||
if db_member.role_id:
|
||||
role = db.query(Role).filter(Role.id == db_member.role_id).first()
|
||||
if role:
|
||||
role_name = role.name
|
||||
return {
|
||||
"id": db_member.id,
|
||||
"user_id": db_member.user_id,
|
||||
"project_id": db_member.project_id,
|
||||
"role": role_name
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
|
||||
def list_project_members(project_id: int, db: Session = Depends(get_db)):
|
||||
return 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()
|
||||
result = []
|
||||
for m in members:
|
||||
role_name = "developer"
|
||||
if m.role_id:
|
||||
role = db.query(Role).filter(Role.id == m.role_id).first()
|
||||
if role:
|
||||
role_name = role.name
|
||||
result.append({
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"role": role_name
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)):
|
||||
def remove_project_member(
|
||||
project_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
check_permission(db, current_user.id, project_id, "member.remove")
|
||||
member = db.query(models.ProjectMember).filter(
|
||||
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
|
||||
).first()
|
||||
|
||||
# Prevent removing project owner (admin role)
|
||||
if member.role_id:
|
||||
role = db.query(Role).filter(Role.id == member.role_id).first()
|
||||
if role and role.name == "admin":
|
||||
# Check if this is the only admin
|
||||
admin_count = db.query(models.ProjectMember).filter(
|
||||
models.ProjectMember.project_id == project_id,
|
||||
models.ProjectMember.role_id == member.role_id
|
||||
).count()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove the last owner of the project")
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
db.delete(member)
|
||||
|
||||
214
app/api/routers/roles.py
Normal file
214
app/api/routers/roles.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Roles and Permissions API router."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role, Permission, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/roles", tags=["Roles"])
|
||||
|
||||
|
||||
# Schemas
|
||||
class PermissionResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
category: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
is_global: bool
|
||||
permission_ids: List[int] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleDetailResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
is_global: bool
|
||||
permissions: List[PermissionResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_global: bool = False
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PermissionAssign(BaseModel):
|
||||
permission_ids: List[int]
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=List[PermissionResponse])
|
||||
def list_permissions(db: Session = Depends(get_db)):
|
||||
"""List all permissions."""
|
||||
return db.query(Permission).all()
|
||||
|
||||
|
||||
@router.get("", response_model=List[RoleResponse])
|
||||
def list_roles(db: Session = Depends(get_db)):
|
||||
"""List all roles."""
|
||||
roles = db.query(Role).all()
|
||||
result = []
|
||||
for role in roles:
|
||||
perm_ids = [rp.permission_id for rp in role.permissions]
|
||||
result.append(RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_global=role.is_global,
|
||||
permission_ids=perm_ids
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{role_id}", response_model=RoleDetailResponse)
|
||||
def get_role(role_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a role with its permissions."""
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
perms = []
|
||||
for rp in role.permissions:
|
||||
perms.append(PermissionResponse(
|
||||
id=rp.permission.id,
|
||||
name=rp.permission.name,
|
||||
description=rp.permission.description,
|
||||
category=rp.permission.category
|
||||
))
|
||||
|
||||
return RoleDetailResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_global=role.is_global,
|
||||
permissions=perms
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_role(role: RoleCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
"""Create a new role. Requires is_admin."""
|
||||
if not getattr(current_user, 'is_admin', False):
|
||||
raise HTTPException(status_code=403, detail="Only admins can create roles")
|
||||
|
||||
existing = db.query(Role).filter(Role.name == role.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Role already exists")
|
||||
|
||||
db_role = Role(**role.model_dump())
|
||||
db.add(db_role)
|
||||
db.commit()
|
||||
db.refresh(db_role)
|
||||
return RoleResponse(
|
||||
id=db_role.id,
|
||||
name=db_role.name,
|
||||
description=db_role.description,
|
||||
is_global=db_role.is_global,
|
||||
permission_ids=[]
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{role_id}", response_model=RoleResponse)
|
||||
def update_role(role_id: int, role: RoleUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
"""Update a role."""
|
||||
if not getattr(current_user, 'is_admin', False):
|
||||
raise HTTPException(status_code=403, detail="Only admins can edit roles")
|
||||
|
||||
db_role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not db_role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
for key, value in role.model_dump(exclude_unset=True).items():
|
||||
setattr(db_role, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_role)
|
||||
|
||||
perm_ids = [rp.permission_id for rp in db_role.permissions]
|
||||
return RoleResponse(
|
||||
id=db_role.id,
|
||||
name=db_role.name,
|
||||
description=db_role.description,
|
||||
is_global=db_role.is_global,
|
||||
permission_ids=perm_ids
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_role(role_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
"""Delete a role."""
|
||||
if not getattr(current_user, 'is_admin', False):
|
||||
raise HTTPException(status_code=403, detail="Only admins can delete roles")
|
||||
|
||||
db_role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not db_role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
member_count = db.query(models.ProjectMember).filter(models.ProjectMember.role_id == role_id).count()
|
||||
if member_count > 0:
|
||||
raise HTTPException(status_code=400, detail="Role is in use by members")
|
||||
|
||||
db.delete(db_role)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{role_id}/permissions", response_model=RoleDetailResponse)
|
||||
def assign_permissions(role_id: int, perm_assign: PermissionAssign, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
"""Assign permissions to a role."""
|
||||
if not getattr(current_user, 'is_admin', False):
|
||||
raise HTTPException(status_code=403, detail="Only admins can edit role permissions")
|
||||
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
|
||||
|
||||
for perm_id in perm_assign.permission_ids:
|
||||
perm = db.query(Permission).filter(Permission.id == perm_id).first()
|
||||
if perm:
|
||||
rp = RolePermission(role_id=role_id, permission_id=perm_id)
|
||||
db.add(rp)
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
perms = []
|
||||
for rp in role.permissions:
|
||||
perms.append(PermissionResponse(
|
||||
id=rp.permission.id,
|
||||
name=rp.permission.name,
|
||||
description=rp.permission.description,
|
||||
category=rp.permission.category
|
||||
))
|
||||
|
||||
return RoleDetailResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_global=role.is_global,
|
||||
permissions=perms
|
||||
)
|
||||
@@ -70,7 +70,7 @@ def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
|
||||
return user
|
||||
|
||||
|
||||
def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
|
||||
def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
|
||||
"""Create default project if configured and not exists."""
|
||||
name = project_cfg.get("name")
|
||||
if not name:
|
||||
@@ -83,6 +83,7 @@ def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
|
||||
project = models.Project(
|
||||
name=name,
|
||||
description=project_cfg.get("description", ""),
|
||||
owner_name=project_cfg.get("owner") or owner_name or "",
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(project)
|
||||
@@ -108,6 +109,6 @@ def run_init(db: Session) -> None:
|
||||
# Default project
|
||||
project_cfg = config.get("default_project")
|
||||
if project_cfg and admin_user:
|
||||
init_default_project(db, project_cfg, admin_user.id)
|
||||
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
||||
|
||||
logger.info("Initialization complete")
|
||||
|
||||
63
app/main.py
63
app/main.py
@@ -34,6 +34,9 @@ from app.api.routers.users import router as users_router
|
||||
from app.api.routers.comments import router as comments_router
|
||||
from app.api.routers.webhooks import router as webhooks_router
|
||||
from app.api.routers.misc import router as misc_router
|
||||
from app.api.routers.monitor import router as monitor_router
|
||||
from app.api.routers.milestones import router as milestones_router
|
||||
from app.api.routers.roles import router as roles_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(issues_router)
|
||||
@@ -42,13 +45,53 @@ app.include_router(users_router)
|
||||
app.include_router(comments_router)
|
||||
app.include_router(webhooks_router)
|
||||
app.include_router(misc_router)
|
||||
app.include_router(monitor_router)
|
||||
app.include_router(milestones_router)
|
||||
app.include_router(roles_router)
|
||||
|
||||
|
||||
# Auto schema migration for lightweight deployments
|
||||
def _migrate_schema():
|
||||
from sqlalchemy import text
|
||||
from app.core.config import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# issues.issue_subtype
|
||||
result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone()
|
||||
if not result:
|
||||
db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL"))
|
||||
# issues.issue_type enum -> varchar
|
||||
result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone()
|
||||
if result and 'enum' in result[1].lower():
|
||||
db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'"))
|
||||
# projects.project_code
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone()
|
||||
if not result:
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
|
||||
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
|
||||
# projects.owner_name
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone()
|
||||
if not result:
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
|
||||
# projects.sub_projects / related_projects
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone()
|
||||
if not result:
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
|
||||
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone()
|
||||
if not result:
|
||||
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
|
||||
except Exception as e:
|
||||
print(f"Migration warning: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Run database migration on startup
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
from app.core.config import Base, engine, SessionLocal
|
||||
from app.models import webhook, apikey, activity, milestone, notification, worklog
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_schema()
|
||||
|
||||
# Initialize from AbstractWizard (admin user, default project, etc.)
|
||||
from app.init_wizard import run_init
|
||||
@@ -57,3 +100,21 @@ def startup():
|
||||
run_init(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Start lightweight monitor polling thread (every 10 minutes)
|
||||
import threading, time
|
||||
from app.services.monitoring import refresh_provider_usage_once
|
||||
|
||||
def _monitor_poll_loop():
|
||||
while True:
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
refresh_provider_usage_once(db2)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db2.close()
|
||||
time.sleep(600)
|
||||
|
||||
t = threading.Thread(target=_monitor_poll_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
from app.models.role_permission import Role
|
||||
import enum
|
||||
|
||||
|
||||
class IssueType(str, enum.Enum):
|
||||
TASK = "task"
|
||||
MEETING = "meeting"
|
||||
SUPPORT = "support"
|
||||
ISSUE = "issue"
|
||||
MAINTENANCE = "maintenance"
|
||||
RESEARCH = "research"
|
||||
REVIEW = "review"
|
||||
STORY = "story"
|
||||
TEST = "test"
|
||||
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交
|
||||
TASK = "task" # legacy generic type
|
||||
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
@@ -33,7 +40,8 @@ class Issue(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
issue_type = Column(Enum(IssueType), default=IssueType.TASK)
|
||||
issue_type = Column(String(32), default=IssueType.ISSUE.value)
|
||||
issue_subtype = Column(String(64), nullable=True)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
|
||||
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
|
||||
|
||||
@@ -85,6 +93,11 @@ class Project(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
project_code = Column(String(16), unique=True, index=True, nullable=True)
|
||||
owner_name = Column(String(128), nullable=False)
|
||||
sub_projects = Column(String(512), nullable=True)
|
||||
related_projects = Column(String(512), nullable=True)
|
||||
repo = Column(String(512), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -120,7 +133,16 @@ class ProjectMember(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
role = Column(String(20), default="dev") # admin, dev, mgr, ops
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||
role = relationship("Role")
|
||||
|
||||
project = relationship("Project", back_populates="members")
|
||||
user = relationship("User", back_populates="project_memberships")
|
||||
|
||||
|
||||
class ProjectCodeCounter(Base):
|
||||
__tablename__ = "project_code_counters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
prefix = Column(String(16), unique=True, index=True, nullable=False)
|
||||
next_value = Column(Integer, default=0)
|
||||
|
||||
78
app/models/monitor.py
Normal file
78
app/models/monitor.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class ProviderAccount(Base):
|
||||
__tablename__ = 'provider_accounts'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
provider = Column(String(32), nullable=False, index=True) # anthropic/openai/minimax/kimi/qwen
|
||||
label = Column(String(128), nullable=False)
|
||||
credential = Column(Text, nullable=False) # TODO: encrypt at rest
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
class ProviderUsageSnapshot(Base):
|
||||
__tablename__ = 'provider_usage_snapshots'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey('provider_accounts.id'), nullable=False, index=True)
|
||||
window_label = Column(String(32), nullable=True) # e.g. 1h / 7d
|
||||
used = Column(Float, nullable=True)
|
||||
limit = Column(Float, nullable=True)
|
||||
usage_pct = Column(Float, nullable=True)
|
||||
reset_at = Column(DateTime(timezone=True), nullable=True)
|
||||
status = Column(String(32), nullable=False, default='unknown') # ok/error/pending/unsupported
|
||||
error = Column(Text, nullable=True)
|
||||
raw_payload = Column(Text, nullable=True)
|
||||
fetched_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class MonitoredServer(Base):
|
||||
__tablename__ = 'monitored_servers'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
identifier = Column(String(128), nullable=False, unique=True)
|
||||
display_name = Column(String(128), nullable=True)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ServerState(Base):
|
||||
__tablename__ = 'server_states'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, unique=True)
|
||||
openclaw_version = Column(String(64), nullable=True)
|
||||
agents_json = Column(Text, nullable=True) # json list
|
||||
cpu_pct = Column(Float, nullable=True)
|
||||
mem_pct = Column(Float, nullable=True)
|
||||
disk_pct = Column(Float, nullable=True)
|
||||
swap_pct = Column(Float, nullable=True)
|
||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
class ServerChallenge(Base):
|
||||
__tablename__ = 'server_challenges'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, index=True)
|
||||
challenge_uuid = Column(String(64), nullable=False, unique=True, index=True)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ServerHandshakeNonce(Base):
|
||||
__tablename__ = 'server_handshake_nonces'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
server_id = Column(Integer, ForeignKey('monitored_servers.id'), nullable=False, index=True)
|
||||
nonce = Column(String(128), nullable=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
44
app/models/role_permission.py
Normal file
44
app/models/role_permission.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Role and Permission models."""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""Role definition - configurable roles."""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), unique=True, nullable=False)
|
||||
description = Column(String(255), nullable=True)
|
||||
is_global = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
permissions = relationship("RolePermission", back_populates="role")
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""Permission definitions - granular permissions."""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(String(255), nullable=True)
|
||||
category = Column(String(50), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
roles = relationship("RolePermission", back_populates="permission")
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Maps roles to permissions."""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False)
|
||||
|
||||
role = relationship("Role", back_populates="permissions")
|
||||
permission = relationship("Permission", back_populates="roles")
|
||||
@@ -5,10 +5,16 @@ from enum import Enum
|
||||
|
||||
|
||||
class IssueTypeEnum(str, Enum):
|
||||
TASK = "task"
|
||||
MEETING = "meeting"
|
||||
SUPPORT = "support"
|
||||
ISSUE = "issue"
|
||||
MAINTENANCE = "maintenance"
|
||||
RESEARCH = "research"
|
||||
REVIEW = "review"
|
||||
STORY = "story"
|
||||
TEST = "test"
|
||||
RESOLUTION = "resolution"
|
||||
TASK = "task" # legacy
|
||||
|
||||
|
||||
class IssueStatusEnum(str, Enum):
|
||||
@@ -30,7 +36,8 @@ class IssuePriorityEnum(str, Enum):
|
||||
class IssueBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
issue_type: IssueTypeEnum = IssueTypeEnum.TASK
|
||||
issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
|
||||
issue_subtype: Optional[str] = None
|
||||
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
|
||||
tags: Optional[str] = None
|
||||
depends_on_id: Optional[int] = None
|
||||
@@ -51,6 +58,8 @@ class IssueCreate(IssueBase):
|
||||
class IssueUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
issue_type: Optional[IssueTypeEnum] = None
|
||||
issue_subtype: Optional[str] = None
|
||||
status: Optional[IssueStatusEnum] = None
|
||||
priority: Optional[IssuePriorityEnum] = None
|
||||
assignee_id: Optional[int] = None
|
||||
@@ -110,7 +119,10 @@ class CommentResponse(CommentBase):
|
||||
# Project schemas
|
||||
class ProjectBase(BaseModel):
|
||||
name: str
|
||||
owner_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sub_projects: Optional[list[str]] = None
|
||||
related_projects: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
@@ -118,13 +130,27 @@ class ProjectCreate(ProjectBase):
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
owner_name: Optional[str] = None
|
||||
sub_projects: Optional[list[str]] = None
|
||||
related_projects: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
owner_name: Optional[str] = None
|
||||
project_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sub_projects: Optional[list[str]] = None
|
||||
related_projects: Optional[list[str]] = None
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
|
||||
class _ProjectResponse_Inactive(ProjectBase):
|
||||
id: int
|
||||
owner_id: int
|
||||
project_code: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -156,7 +182,6 @@ class UserResponse(UserBase):
|
||||
# Project Member schemas
|
||||
class ProjectMemberBase(BaseModel):
|
||||
user_id: int
|
||||
project_id: int
|
||||
role: str = "dev"
|
||||
|
||||
|
||||
@@ -164,8 +189,11 @@ class ProjectMemberCreate(ProjectMemberBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectMemberResponse(ProjectMemberBase):
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
project_id: int
|
||||
role: str = "dev"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -179,7 +207,7 @@ class MilestoneBase(BaseModel):
|
||||
|
||||
|
||||
class MilestoneCreate(MilestoneBase):
|
||||
project_id: int
|
||||
pass
|
||||
|
||||
|
||||
class MilestoneUpdate(BaseModel):
|
||||
|
||||
18
app/services/activity.py
Normal file
18
app/services/activity.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Activity logging helper — auto-record CRUD operations."""
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.activity import ActivityLog
|
||||
|
||||
|
||||
def log_activity(db: Session, action: str, entity_type: str, entity_id: int, user_id: int = None, details: dict = None):
|
||||
"""Record an activity log entry."""
|
||||
entry = ActivityLog(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
user_id=user_id,
|
||||
details=json.dumps(details) if details else None,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
return entry
|
||||
63
app/services/crypto_box.py
Normal file
63
app/services/crypto_box.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
|
||||
KEY_DIR = Path(os.getenv('MONITOR_KEY_DIR', '/config/monitor_keys'))
|
||||
PRIV_PATH = KEY_DIR / 'monitor_private.pem'
|
||||
PUB_PATH = KEY_DIR / 'monitor_public.pem'
|
||||
|
||||
|
||||
def ensure_keypair() -> None:
|
||||
KEY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if PRIV_PATH.exists() and PUB_PATH.exists():
|
||||
return
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
PRIV_PATH.write_bytes(private_pem)
|
||||
PUB_PATH.write_bytes(public_pem)
|
||||
|
||||
|
||||
def get_public_key_info() -> Dict[str, str]:
|
||||
ensure_keypair()
|
||||
pem = PUB_PATH.read_text()
|
||||
kid = hashlib.sha256(pem.encode()).hexdigest()[:16]
|
||||
return {'public_key_pem': pem, 'key_id': kid}
|
||||
|
||||
|
||||
def decrypt_payload_b64(ciphertext_b64: str) -> Dict[str, Any]:
|
||||
ensure_keypair()
|
||||
private_key = serialization.load_pem_private_key(PRIV_PATH.read_bytes(), password=None)
|
||||
plaintext = private_key.decrypt(
|
||||
base64.b64decode(ciphertext_b64),
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
)
|
||||
obj = json.loads(plaintext.decode())
|
||||
return obj
|
||||
|
||||
|
||||
def ts_within(ts_iso: str, max_minutes: int = 10) -> bool:
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_iso.replace('Z', '+00:00'))
|
||||
except Exception:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
return abs((now - ts).total_seconds()) <= max_minutes * 60
|
||||
305
app/services/monitoring.py
Normal file
305
app/services/monitoring.py
Normal file
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import Issue
|
||||
from app.models.monitor import ProviderAccount, ProviderUsageSnapshot, MonitoredServer, ServerState
|
||||
|
||||
_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_credential(raw: str) -> Dict[str, Any]:
|
||||
raw = (raw or '').strip()
|
||||
if raw.startswith('{'):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {'api_key': raw}
|
||||
return {'api_key': raw}
|
||||
|
||||
|
||||
def _parse_reset_at(value) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_usage_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
used = payload.get('used') or payload.get('usage') or payload.get('consumed') or payload.get('total_usage')
|
||||
limit = payload.get('limit') or payload.get('quota') or payload.get('hard_limit') or payload.get('total')
|
||||
remaining = payload.get('remain') or payload.get('remaining') or payload.get('left')
|
||||
usage_pct = payload.get('usage_pct') or payload.get('percent') or payload.get('usage_percent')
|
||||
window_label = payload.get('window') or payload.get('window_label')
|
||||
reset_at = payload.get('reset_at') or payload.get('reset_time') or payload.get('reset')
|
||||
|
||||
if used is None and remaining is not None and limit is not None:
|
||||
try:
|
||||
used = float(limit) - float(remaining)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usage_pct is None and used is not None and limit:
|
||||
try:
|
||||
usage_pct = round(float(used) / float(limit) * 100, 2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'window_label': window_label,
|
||||
'used': used,
|
||||
'limit': limit,
|
||||
'usage_pct': usage_pct,
|
||||
'reset_at': _parse_reset_at(reset_at),
|
||||
'raw': payload,
|
||||
}
|
||||
|
||||
|
||||
def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
|
||||
key = 'issue_stats_24h'
|
||||
now = _now()
|
||||
hit = _CACHE.get(key)
|
||||
if hit and (now - hit['at']).total_seconds() < ttl_seconds:
|
||||
return hit['data']
|
||||
|
||||
since = now - timedelta(hours=24)
|
||||
total = db.query(Issue).count()
|
||||
new_24h = db.query(Issue).filter(Issue.created_at >= since).count()
|
||||
processed_24h = db.query(Issue).filter(
|
||||
Issue.updated_at != None,
|
||||
Issue.updated_at >= since,
|
||||
Issue.status.in_(['resolved', 'closed'])
|
||||
).count()
|
||||
data = {
|
||||
'total_issues': total,
|
||||
'new_issues_24h': new_24h,
|
||||
'processed_issues_24h': processed_24h,
|
||||
'computed_at': now.isoformat(),
|
||||
'cache_ttl_seconds': ttl_seconds,
|
||||
}
|
||||
_CACHE[key] = {'at': now, 'data': data}
|
||||
return data
|
||||
|
||||
|
||||
def _provider_headers(provider: str, credential: str, extra: Dict[str, Any] | None = None):
|
||||
extra = extra or {}
|
||||
if extra.get('auth_header'):
|
||||
val = extra.get('auth_value')
|
||||
if not val:
|
||||
scheme = extra.get('auth_scheme')
|
||||
val = f"{scheme} {credential}" if scheme else credential
|
||||
return {extra['auth_header']: val}
|
||||
if provider == 'openai':
|
||||
return {'Authorization': f'Bearer {credential}'}
|
||||
if provider == 'anthropic':
|
||||
return {'x-api-key': credential, 'anthropic-version': '2023-06-01'}
|
||||
return {}
|
||||
|
||||
|
||||
def test_provider_connection(provider: str, credential: str):
|
||||
provider = provider.lower()
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
try:
|
||||
if provider == 'openai':
|
||||
r = requests.get('https://api.openai.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code == 200, f'status={r.status_code}'
|
||||
if provider == 'anthropic':
|
||||
r = requests.get('https://api.anthropic.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code == 200, f'status={r.status_code}'
|
||||
usage_url = info.get('usage_url') or info.get('test_url')
|
||||
if provider == 'kimi' and not usage_url:
|
||||
usage_url = 'https://www.kimi.com/api/user/usage'
|
||||
if usage_url:
|
||||
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code < 500, f'status={r.status_code}'
|
||||
if provider in {'minimax', 'kimi', 'qwen'}:
|
||||
return True, 'accepted (connectivity check pending provider-specific adapter)'
|
||||
return False, 'unsupported provider'
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
headers = _provider_headers('openai', key, info)
|
||||
today = _now().date()
|
||||
start = (today - timedelta(days=7)).isoformat()
|
||||
end = today.isoformat()
|
||||
usage_url = info.get('usage_url') or f'https://api.openai.com/v1/dashboard/billing/usage?start_date={start}&end_date={end}'
|
||||
sub_url = info.get('subscription_url') or 'https://api.openai.com/v1/dashboard/billing/subscription'
|
||||
u = requests.get(usage_url, headers=headers, timeout=12)
|
||||
s = requests.get(sub_url, headers=headers, timeout=12)
|
||||
if u.status_code != 200 or s.status_code != 200:
|
||||
return 'error', {'error': f'usage:{u.status_code}, subscription:{s.status_code}'}
|
||||
usage = u.json()
|
||||
sub = s.json()
|
||||
total_usage = usage.get('total_usage')
|
||||
hard_limit = sub.get('hard_limit_usd')
|
||||
reset_at_ts = sub.get('billing_cycle_anchor')
|
||||
reset_at = datetime.fromtimestamp(reset_at_ts, tz=timezone.utc) if reset_at_ts else None
|
||||
usage_pct = None
|
||||
if total_usage is not None and hard_limit:
|
||||
usage_pct = round(total_usage / hard_limit * 100, 2)
|
||||
return 'ok', {
|
||||
'window_label': '7d',
|
||||
'used': total_usage,
|
||||
'limit': hard_limit,
|
||||
'usage_pct': usage_pct,
|
||||
'reset_at': reset_at,
|
||||
'raw': {'usage': usage, 'subscription': sub},
|
||||
}
|
||||
|
||||
|
||||
def _anthropic_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url')
|
||||
if not usage_url:
|
||||
return 'unsupported', {'error': 'anthropic usage API not configured'}
|
||||
r = requests.get(usage_url, headers=_provider_headers('anthropic', key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
|
||||
payload = payload['data']
|
||||
return 'ok', _normalize_usage_payload(payload)
|
||||
|
||||
|
||||
def _kimi_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url') or 'https://www.kimi.com/api/user/usage'
|
||||
r = requests.get(usage_url, headers=_provider_headers('kimi', key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
|
||||
payload = payload['data']
|
||||
return 'ok', _normalize_usage_payload(payload)
|
||||
|
||||
|
||||
def _minimax_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url')
|
||||
if not usage_url:
|
||||
return 'unsupported', {'error': 'minimax usage API not configured'}
|
||||
r = requests.get(usage_url, headers=_provider_headers('minimax', key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
if isinstance(payload, dict) and 'data' in payload and isinstance(payload['data'], dict):
|
||||
payload = payload['data']
|
||||
return 'ok', _normalize_usage_payload(payload)
|
||||
|
||||
|
||||
def _generic_usage(provider: str, credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url')
|
||||
if not usage_url:
|
||||
return 'unsupported', {'error': f'{provider} usage API not configured'}
|
||||
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
return 'ok', _normalize_usage_payload(payload)
|
||||
|
||||
|
||||
def refresh_provider_usage_once(db: Session):
|
||||
accounts = db.query(ProviderAccount).filter(ProviderAccount.is_enabled == True).all()
|
||||
now = _now()
|
||||
for a in accounts:
|
||||
status = 'pending'
|
||||
payload: Dict[str, Any] = {}
|
||||
if a.provider == 'openai':
|
||||
status, payload = _openai_usage(a.credential)
|
||||
elif a.provider == 'anthropic':
|
||||
status, payload = _anthropic_usage(a.credential)
|
||||
elif a.provider == 'kimi':
|
||||
status, payload = _kimi_usage(a.credential)
|
||||
elif a.provider == 'minimax':
|
||||
status, payload = _minimax_usage(a.credential)
|
||||
elif a.provider == 'qwen':
|
||||
status, payload = _generic_usage(a.provider, a.credential)
|
||||
else:
|
||||
ok, msg = test_provider_connection(a.provider, a.credential)
|
||||
status = 'ok' if ok else 'error'
|
||||
payload = {'error': None if ok else msg}
|
||||
|
||||
snap = ProviderUsageSnapshot(
|
||||
account_id=a.id,
|
||||
window_label=payload.get('window_label'),
|
||||
used=payload.get('used'),
|
||||
limit=payload.get('limit'),
|
||||
usage_pct=payload.get('usage_pct'),
|
||||
reset_at=payload.get('reset_at'),
|
||||
status=status,
|
||||
error=payload.get('error'),
|
||||
raw_payload=json.dumps(payload.get('raw') or payload, ensure_ascii=False),
|
||||
fetched_at=now,
|
||||
)
|
||||
db.add(snap)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_provider_usage_view(db: Session):
|
||||
accounts = db.query(ProviderAccount).filter(ProviderAccount.is_enabled == True).all()
|
||||
rows = []
|
||||
for a in accounts:
|
||||
snap = db.query(ProviderUsageSnapshot).filter(ProviderUsageSnapshot.account_id == a.id).order_by(ProviderUsageSnapshot.fetched_at.desc()).first()
|
||||
rows.append({
|
||||
'account_id': a.id,
|
||||
'provider': a.provider,
|
||||
'label': a.label,
|
||||
'window': snap.window_label if snap else None,
|
||||
'usage_pct': snap.usage_pct if snap else None,
|
||||
'used': snap.used if snap else None,
|
||||
'limit': snap.limit if snap else None,
|
||||
'reset_at': snap.reset_at.isoformat() if snap and snap.reset_at else None,
|
||||
'status': snap.status if snap else 'pending',
|
||||
'error': snap.error if snap else None,
|
||||
'fetched_at': snap.fetched_at.isoformat() if snap and snap.fetched_at else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def get_server_states_view(db: Session, offline_after_minutes: int = 7):
|
||||
now = _now()
|
||||
servers = db.query(MonitoredServer).filter(MonitoredServer.is_enabled == True).all()
|
||||
out = []
|
||||
for s in servers:
|
||||
st = db.query(ServerState).filter(ServerState.server_id == s.id).first()
|
||||
last_seen = st.last_seen_at if st else None
|
||||
online = bool(last_seen and (now - last_seen).total_seconds() <= offline_after_minutes * 60)
|
||||
out.append({
|
||||
'server_id': s.id,
|
||||
'identifier': s.identifier,
|
||||
'display_name': s.display_name or s.identifier,
|
||||
'online': online,
|
||||
'openclaw_version': st.openclaw_version if st else None,
|
||||
'cpu_pct': st.cpu_pct if st else None,
|
||||
'mem_pct': st.mem_pct if st else None,
|
||||
'disk_pct': st.disk_pct if st else None,
|
||||
'swap_pct': st.swap_pct if st else None,
|
||||
'agents': json.loads(st.agents_json) if st and st.agents_json else [],
|
||||
'last_seen_at': last_seen.isoformat() if last_seen else None,
|
||||
})
|
||||
return out
|
||||
63
docs/monitor-provider-credentials.md
Normal file
63
docs/monitor-provider-credentials.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Provider 账号凭证格式(Monitor)
|
||||
|
||||
默认情况下,`credential` 可以直接填写 API Key 字符串。
|
||||
如果需要配置自定义 usage 端点,请使用 JSON 字符串。
|
||||
|
||||
## 基础格式
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-...",
|
||||
"usage_url": "https://.../usage",
|
||||
"auth_header": "Authorization",
|
||||
"auth_scheme": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
- `api_key`: API key(必填)
|
||||
- `usage_url`: 统计用量的 GET 端点(可选,minimax/kimi/qwen 推荐填写)
|
||||
- `auth_header`: 自定义鉴权头名(可选)
|
||||
- `auth_scheme`: 鉴权 scheme(如 `Bearer`),会拼成 `Bearer <api_key>`
|
||||
- `auth_value`: 直接指定头值(优先级高于 scheme)
|
||||
|
||||
## OpenAI
|
||||
默认使用 OpenAI 官方 billing endpoints(7天窗口):
|
||||
- `https://api.openai.com/v1/dashboard/billing/usage`
|
||||
- `https://api.openai.com/v1/dashboard/billing/subscription`
|
||||
|
||||
如需自定义可使用 JSON:
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-...",
|
||||
"usage_url": "https://api.openai.com/v1/dashboard/billing/usage?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD",
|
||||
"subscription_url": "https://api.openai.com/v1/dashboard/billing/subscription"
|
||||
}
|
||||
```
|
||||
|
||||
## Anthropic
|
||||
官方 usage API 需要自行提供 `usage_url`(不同组织可能不同):
|
||||
```json
|
||||
{
|
||||
"api_key": "ak-...",
|
||||
"usage_url": "https://api.anthropic.com/.../usage"
|
||||
}
|
||||
```
|
||||
|
||||
## Minimax / Kimi / Qwen
|
||||
目前需要你提供 `usage_url`(具体端点取决于部署/账号):
|
||||
```json
|
||||
{
|
||||
"api_key": "...",
|
||||
"usage_url": "https://.../usage",
|
||||
"auth_header": "Authorization",
|
||||
"auth_scheme": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
## Kimi
|
||||
推荐 usage_url: https://www.kimi.com/api/user/usage
|
||||
Authorization: Bearer <API_KEY>
|
||||
|
||||
## Minimax
|
||||
推荐 usage_url: https://platform.minimax.io/v1/api/openplatform/coding_plan/remains
|
||||
Authorization: Bearer <API_KEY>
|
||||
68
docs/openclaw-monitor-plugin-plan.md
Normal file
68
docs/openclaw-monitor-plugin-plan.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# OpenClaw Monitor Agent Plugin 开发计划(草案)
|
||||
|
||||
## 目标
|
||||
让被监测服务器通过 WebSocket 主动接入 HarborForge Backend,并持续上报:
|
||||
- OpenClaw 版本
|
||||
- agent 列表
|
||||
- 每 5 分钟主机指标(CPU/MEM/DISK/SWAP)
|
||||
- agent 状态变更事件
|
||||
|
||||
## 握手流程
|
||||
1. Admin 在 HarborForge 后台添加 server identifier
|
||||
2. Admin 生成 challenge(10 分钟有效)
|
||||
3. 插件请求 `GET /monitor/public/server-public-key` 获取公钥
|
||||
4. 插件构造 payload:
|
||||
- `identifier`
|
||||
- `challenge_uuid`
|
||||
- `nonce`(随机)
|
||||
- `ts`(ISO8601)
|
||||
5. 使用 RSA-OAEP(SHA256) 公钥加密,base64 后作为 `encrypted_payload` 发给 `WS /monitor/server/ws`
|
||||
6. 握手成功后进入事件上报通道
|
||||
|
||||
## 插件事件协议
|
||||
### server.hello
|
||||
```json
|
||||
{
|
||||
"event": "server.hello",
|
||||
"payload": {
|
||||
"openclaw_version": "x.y.z",
|
||||
"agents": [{"id": "a1", "name": "agent-1", "status": "idle"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### server.metrics(每 5 分钟)
|
||||
```json
|
||||
{
|
||||
"event": "server.metrics",
|
||||
"payload": {
|
||||
"cpu_pct": 21.3,
|
||||
"mem_pct": 42.1,
|
||||
"disk_pct": 55.9,
|
||||
"swap_pct": 0.0,
|
||||
"agents": [{"id": "a1", "name": "agent-1", "status": "busy"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### agent.status_changed(可选)
|
||||
```json
|
||||
{
|
||||
"event": "agent.status_changed",
|
||||
"payload": {
|
||||
"agents": [{"id": "a1", "name": "agent-1", "status": "focus"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 实施里程碑
|
||||
- M1: Node/Python CLI 插件最小握手联通
|
||||
- M2: 指标采集 + 周期上报
|
||||
- M3: agent 状态采集与变更事件
|
||||
- M4: 守护化(systemd)+ 断线重连 + 本地日志
|
||||
|
||||
## 风险与注意事项
|
||||
- 时钟漂移会导致 `ts` 校验失败(建议 NTP)
|
||||
- challenge 仅一次可用,重复使用会被拒绝
|
||||
- nonce 重放会被拒绝
|
||||
- 需要保证插件本地安全保存 identifier/challenge(短期)
|
||||
@@ -11,3 +11,4 @@ python-multipart==0.0.6
|
||||
alembic==1.13.1
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.27.0
|
||||
requests==2.31.0
|
||||
|
||||
Reference in New Issue
Block a user