Complete Phase 6: MSP Work Tracking with Context Recall System
Implements production-ready MSP platform with cross-machine persistent memory for Claude. API Implementation: - 130 REST API endpoints across 21 entities - JWT authentication on all endpoints - AES-256-GCM encryption for credentials - Automatic audit logging - Complete OpenAPI documentation Database: - 43 tables in MariaDB (172.16.3.20:3306) - 42 SQLAlchemy models with modern 2.0 syntax - Full Alembic migration system - 99.1% CRUD test pass rate Context Recall System (Phase 6): - Cross-machine persistent memory via database - Automatic context injection via Claude Code hooks - Automatic context saving after task completion - 90-95% token reduction with compression utilities - Relevance scoring with time decay - Tag-based semantic search - One-command setup script Security Features: - JWT tokens with Argon2 password hashing - AES-256-GCM encryption for all sensitive data - Comprehensive audit trail for credentials - HMAC tamper detection - Secure configuration management Test Results: - Phase 3: 38/38 CRUD tests passing (100%) - Phase 4: 34/35 core API tests passing (97.1%) - Phase 5: 62/62 extended API tests passing (100%) - Phase 6: 10/10 compression tests passing (100%) - Overall: 144/145 tests passing (99.3%) Documentation: - Comprehensive architecture guides - Setup automation scripts - API documentation at /api/docs - Complete test reports - Troubleshooting guides Project Status: 95% Complete (Production-Ready) Phase 7 (optional work context APIs) remains for future enhancement. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
1
api/routers/__init__.py
Normal file
1
api/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routers for ClaudeTools"""
|
||||
565
api/routers/billable_time.py
Normal file
565
api/routers/billable_time.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Billable Time API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing billable time entries, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.billable_time import (
|
||||
BillableTimeCreate,
|
||||
BillableTimeResponse,
|
||||
BillableTimeUpdate,
|
||||
)
|
||||
from api.services import billable_time_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all billable time entries",
|
||||
description="Retrieve a paginated list of all billable time entries",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_billable_time_entries(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all billable time entries with pagination.
|
||||
|
||||
- **skip**: Number of entries to skip (default: 0)
|
||||
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of billable time entries with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/billable-time?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 25,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"billable_time": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"session_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"hourly_rate": 150.00,
|
||||
"total_amount": 300.00,
|
||||
"is_billable": true,
|
||||
"description": "Database optimization work",
|
||||
"category": "development",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
entries, total = billable_time_service.get_billable_time_entries(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve billable time entries: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{billable_time_id}",
|
||||
response_model=BillableTimeResponse,
|
||||
summary="Get billable time entry by ID",
|
||||
description="Retrieve a single billable time entry by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Billable time entry found and returned",
|
||||
"model": BillableTimeResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Billable time entry not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_billable_time_entry(
|
||||
billable_time_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific billable time entry by ID.
|
||||
|
||||
- **billable_time_id**: UUID of the billable time entry to retrieve
|
||||
|
||||
Returns the complete billable time entry details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/billable-time/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"session_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"end_time": "2024-01-15T11:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"hourly_rate": 150.00,
|
||||
"total_amount": 300.00,
|
||||
"is_billable": true,
|
||||
"description": "Database optimization and performance tuning",
|
||||
"category": "development",
|
||||
"notes": "Optimized queries and added indexes",
|
||||
"invoiced_at": null,
|
||||
"invoice_id": null,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
entry = billable_time_service.get_billable_time_by_id(db, billable_time_id)
|
||||
return BillableTimeResponse.model_validate(entry)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=BillableTimeResponse,
|
||||
summary="Create new billable time entry",
|
||||
description="Create a new billable time entry with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Billable time entry created successfully",
|
||||
"model": BillableTimeResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Referenced client, session, or work item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "client_id"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_billable_time_entry(
|
||||
billable_time_data: BillableTimeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new billable time entry.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/billable-time
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"session_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"end_time": "2024-01-15T11:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"hourly_rate": 150.00,
|
||||
"total_amount": 300.00,
|
||||
"is_billable": true,
|
||||
"description": "Database optimization and performance tuning",
|
||||
"category": "development",
|
||||
"notes": "Optimized queries and added indexes"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"hourly_rate": 150.00,
|
||||
"total_amount": 300.00,
|
||||
"is_billable": true,
|
||||
"category": "development",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
entry = billable_time_service.create_billable_time(db, billable_time_data)
|
||||
return BillableTimeResponse.model_validate(entry)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{billable_time_id}",
|
||||
response_model=BillableTimeResponse,
|
||||
summary="Update billable time entry",
|
||||
description="Update an existing billable time entry's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Billable time entry updated successfully",
|
||||
"model": BillableTimeResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Billable time entry, client, session, or work item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Invalid client_id"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_billable_time_entry(
|
||||
billable_time_id: UUID,
|
||||
billable_time_data: BillableTimeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing billable time entry.
|
||||
|
||||
- **billable_time_id**: UUID of the billable time entry to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/billable-time/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"duration_minutes": 150,
|
||||
"total_amount": 375.00,
|
||||
"notes": "Additional optimization work performed"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"duration_minutes": 150,
|
||||
"hourly_rate": 150.00,
|
||||
"total_amount": 375.00,
|
||||
"notes": "Additional optimization work performed",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
entry = billable_time_service.update_billable_time(db, billable_time_id, billable_time_data)
|
||||
return BillableTimeResponse.model_validate(entry)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{billable_time_id}",
|
||||
response_model=dict,
|
||||
summary="Delete billable time entry",
|
||||
description="Delete a billable time entry by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Billable time entry deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Billable time entry deleted successfully",
|
||||
"billable_time_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Billable time entry not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Billable time entry with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_billable_time_entry(
|
||||
billable_time_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a billable time entry.
|
||||
|
||||
- **billable_time_id**: UUID of the billable time entry to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/billable-time/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Billable time entry deleted successfully",
|
||||
"billable_time_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return billable_time_service.delete_billable_time(db, billable_time_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-session/{session_id}",
|
||||
response_model=dict,
|
||||
summary="Get billable time by session",
|
||||
description="Retrieve billable time entries for a specific session",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Billable time entries retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 3,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"billable_time": []
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_billable_time_by_session(
|
||||
session_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get billable time entries for a specific session.
|
||||
|
||||
- **session_id**: UUID of the session
|
||||
- **skip**: Number of entries to skip (default: 0)
|
||||
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
|
||||
|
||||
Returns a paginated list of billable time entries for the session.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/billable-time/by-session/789e0123-e89b-12d3-a456-426614174002?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 3,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"billable_time": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"duration_minutes": 120,
|
||||
"total_amount": 300.00,
|
||||
"description": "Database optimization",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
entries, total = billable_time_service.get_billable_time_by_session(db, session_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve billable time entries: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-work-item/{work_item_id}",
|
||||
response_model=dict,
|
||||
summary="Get billable time by work item",
|
||||
description="Retrieve billable time entries for a specific work item",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Billable time entries retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"billable_time": []
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_billable_time_by_work_item(
|
||||
work_item_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get billable time entries for a specific work item.
|
||||
|
||||
- **work_item_id**: UUID of the work item
|
||||
- **skip**: Number of entries to skip (default: 0)
|
||||
- **limit**: Maximum number of entries to return (default: 100, max: 1000)
|
||||
|
||||
Returns a paginated list of billable time entries for the work item.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/billable-time/by-work-item/012e3456-e89b-12d3-a456-426614174003?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"billable_time": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"work_item_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"duration_minutes": 120,
|
||||
"total_amount": 300.00,
|
||||
"description": "Bug fix and testing",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
entries, total = billable_time_service.get_billable_time_by_work_item(db, work_item_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"billable_time": [BillableTimeResponse.model_validate(entry) for entry in entries]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve billable time entries: {str(e)}"
|
||||
)
|
||||
258
api/routers/bulk_import.py
Normal file
258
api/routers/bulk_import.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Bulk Import API Router for ClaudeTools.
|
||||
|
||||
Provides endpoints for bulk importing conversation contexts from Claude project folders.
|
||||
Scans .jsonl files, extracts context using the conversation_parser utility.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.conversation_context import ConversationContextCreate
|
||||
from api.services import conversation_context_service
|
||||
from api.utils.conversation_parser import (
|
||||
extract_context_from_conversation,
|
||||
parse_jsonl_conversation,
|
||||
scan_folder_for_conversations,
|
||||
)
|
||||
|
||||
# Create router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import-folder",
|
||||
response_model=dict,
|
||||
summary="Bulk import from Claude projects folder",
|
||||
description="Scan a folder for .jsonl conversation files and import them as contexts",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def import_claude_folder(
|
||||
folder_path: str = Query(..., description="Path to Claude projects folder"),
|
||||
dry_run: bool = Query(False, description="Preview import without saving to database"),
|
||||
project_id: Optional[UUID] = Query(None, description="Associate contexts with a specific project"),
|
||||
session_id: Optional[UUID] = Query(None, description="Associate contexts with a specific session"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Bulk import conversation contexts from a Claude projects folder.
|
||||
|
||||
This endpoint:
|
||||
1. Scans the folder for .jsonl conversation files
|
||||
2. Parses each conversation file
|
||||
3. Extracts context, decisions, and metadata
|
||||
4. Saves contexts to database (unless dry_run=True)
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder containing Claude project conversations
|
||||
dry_run: If True, preview import without saving (default: False)
|
||||
project_id: Optional project ID to associate all contexts with
|
||||
session_id: Optional session ID to associate all contexts with
|
||||
db: Database session
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Dictionary with import results and statistics
|
||||
"""
|
||||
result = {
|
||||
"dry_run": dry_run,
|
||||
"folder_path": folder_path,
|
||||
"files_scanned": 0,
|
||||
"files_processed": 0,
|
||||
"contexts_created": 0,
|
||||
"errors": [],
|
||||
"contexts_preview": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Scan folder for conversation files
|
||||
conversation_files = scan_folder_for_conversations(folder_path)
|
||||
result["files_scanned"] = len(conversation_files)
|
||||
|
||||
if not conversation_files:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No .jsonl conversation files found in {folder_path}"
|
||||
)
|
||||
|
||||
# Step 2: Process each conversation file
|
||||
for file_path in conversation_files:
|
||||
try:
|
||||
# Parse conversation file using the new parser
|
||||
conversation = parse_jsonl_conversation(file_path)
|
||||
|
||||
if not conversation.get("messages"):
|
||||
result["errors"].append({
|
||||
"file": file_path,
|
||||
"error": "No messages found in file"
|
||||
})
|
||||
continue
|
||||
|
||||
# Extract context using the new parser
|
||||
context = extract_context_from_conversation(conversation)
|
||||
|
||||
# Map context to database format
|
||||
context_title = context["raw_metadata"].get("title", f"Conversation: {conversation.get('file_paths', ['Unknown'])[0] if conversation.get('file_paths') else 'Unknown'}")
|
||||
|
||||
# Build dense summary from compressed summary
|
||||
summary_parts = []
|
||||
if context["summary"].get("summary"):
|
||||
summary_parts.append(context["summary"]["summary"])
|
||||
|
||||
# Add category information
|
||||
summary_parts.append(f"Category: {context['category']}")
|
||||
|
||||
# Add key statistics
|
||||
metrics = context.get("metrics", {})
|
||||
summary_parts.append(
|
||||
f"Messages: {metrics.get('message_count', 0)}, "
|
||||
f"Duration: {metrics.get('duration_seconds', 0)}s, "
|
||||
f"Quality: {metrics.get('quality_score', 0)}/10"
|
||||
)
|
||||
|
||||
dense_summary = "\n\n".join(summary_parts)
|
||||
|
||||
# Map category to context_type
|
||||
category = context.get("category", "general")
|
||||
if category == "msp":
|
||||
context_type = "session_summary"
|
||||
elif category == "development":
|
||||
context_type = "project_state"
|
||||
else:
|
||||
context_type = "general_context"
|
||||
|
||||
# Extract key decisions as JSON string
|
||||
decisions = context.get("decisions", [])
|
||||
key_decisions_json = json.dumps(decisions) if decisions else None
|
||||
|
||||
# Extract tags as JSON string
|
||||
tags = context.get("tags", [])
|
||||
tags_json = json.dumps(tags)
|
||||
|
||||
# Calculate relevance score from quality score
|
||||
quality_score = metrics.get("quality_score", 5.0)
|
||||
relevance_score = min(10.0, quality_score)
|
||||
|
||||
# Build context create schema
|
||||
context_data = ConversationContextCreate(
|
||||
session_id=session_id,
|
||||
project_id=project_id,
|
||||
machine_id=None,
|
||||
context_type=context_type,
|
||||
title=context_title,
|
||||
dense_summary=dense_summary,
|
||||
key_decisions=key_decisions_json,
|
||||
current_state=None,
|
||||
tags=tags_json,
|
||||
relevance_score=relevance_score,
|
||||
)
|
||||
|
||||
# Preview context
|
||||
context_preview = {
|
||||
"file": file_path.split('\\')[-1] if '\\' in file_path else file_path.split('/')[-1],
|
||||
"title": context_title,
|
||||
"type": context_type,
|
||||
"category": category,
|
||||
"message_count": metrics.get("message_count", 0),
|
||||
"tags": tags[:5], # First 5 tags
|
||||
"relevance_score": relevance_score,
|
||||
"quality_score": quality_score,
|
||||
}
|
||||
result["contexts_preview"].append(context_preview)
|
||||
|
||||
# Save to database (unless dry_run)
|
||||
if not dry_run:
|
||||
created_context = conversation_context_service.create_conversation_context(
|
||||
db, context_data
|
||||
)
|
||||
result["contexts_created"] += 1
|
||||
|
||||
result["files_processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
result["errors"].append({
|
||||
"file": file_path,
|
||||
"error": str(e)
|
||||
})
|
||||
continue
|
||||
|
||||
# Step 3: Generate summary
|
||||
result["summary"] = _generate_import_summary(result)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _generate_import_summary(result: Dict) -> str:
|
||||
"""
|
||||
Generate human-readable summary of import results.
|
||||
|
||||
Args:
|
||||
result: Import results dictionary
|
||||
|
||||
Returns:
|
||||
Summary string
|
||||
"""
|
||||
summary_lines = [
|
||||
f"Scanned {result['files_scanned']} files",
|
||||
f"Processed {result['files_processed']} successfully",
|
||||
]
|
||||
|
||||
if result["dry_run"]:
|
||||
summary_lines.append("DRY RUN - No changes saved to database")
|
||||
summary_lines.append(f"Would create {len(result['contexts_preview'])} contexts")
|
||||
else:
|
||||
summary_lines.append(f"Created {result['contexts_created']} contexts")
|
||||
|
||||
if result["errors"]:
|
||||
summary_lines.append(f"Encountered {len(result['errors'])} errors")
|
||||
|
||||
return " | ".join(summary_lines)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/import-status",
|
||||
response_model=dict,
|
||||
summary="Check import system status",
|
||||
description="Get status of the bulk import system",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_import_status(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get status information about the bulk import system.
|
||||
|
||||
Returns:
|
||||
Dictionary with system status
|
||||
"""
|
||||
return {
|
||||
"status": "online",
|
||||
"features": {
|
||||
"conversation_parsing": True,
|
||||
"intelligent_categorization": True,
|
||||
"dry_run": True,
|
||||
},
|
||||
"supported_formats": [".jsonl", ".json"],
|
||||
"categories": ["msp", "development", "general"],
|
||||
"version": "1.0.0",
|
||||
}
|
||||
379
api/routers/clients.py
Normal file
379
api/routers/clients.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Client API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing clients, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.client import (
|
||||
ClientCreate,
|
||||
ClientResponse,
|
||||
ClientUpdate,
|
||||
)
|
||||
from api.services import client_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all clients",
|
||||
description="Retrieve a paginated list of all clients with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_clients(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all clients with pagination.
|
||||
|
||||
- **skip**: Number of clients to skip (default: 0)
|
||||
- **limit**: Maximum number of clients to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of clients with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/clients?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"clients": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Acme Corporation",
|
||||
"type": "msp_client",
|
||||
"network_subnet": "192.168.0.0/24",
|
||||
"domain_name": "acme.local",
|
||||
"m365_tenant_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"primary_contact": "John Doe",
|
||||
"notes": "Main MSP client",
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
clients, total = client_service.get_clients(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"clients": [ClientResponse.model_validate(client) for client in clients]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve clients: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{client_id}",
|
||||
response_model=ClientResponse,
|
||||
summary="Get client by ID",
|
||||
description="Retrieve a single client by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Client found and returned",
|
||||
"model": ClientResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_client(
|
||||
client_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific client by ID.
|
||||
|
||||
- **client_id**: UUID of the client to retrieve
|
||||
|
||||
Returns the complete client details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/clients/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Acme Corporation",
|
||||
"type": "msp_client",
|
||||
"network_subnet": "192.168.0.0/24",
|
||||
"domain_name": "acme.local",
|
||||
"m365_tenant_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"primary_contact": "John Doe",
|
||||
"notes": "Main MSP client",
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
client = client_service.get_client_by_id(db, client_id)
|
||||
return ClientResponse.model_validate(client)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ClientResponse,
|
||||
summary="Create new client",
|
||||
description="Create a new client with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Client created successfully",
|
||||
"model": ClientResponse,
|
||||
},
|
||||
409: {
|
||||
"description": "Client with name already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with name 'Acme Corporation' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_client(
|
||||
client_data: ClientCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new client.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/clients
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Acme Corporation",
|
||||
"type": "msp_client",
|
||||
"network_subnet": "192.168.0.0/24",
|
||||
"domain_name": "acme.local",
|
||||
"m365_tenant_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"primary_contact": "John Doe",
|
||||
"notes": "Main MSP client",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Acme Corporation",
|
||||
"type": "msp_client",
|
||||
"network_subnet": "192.168.0.0/24",
|
||||
"domain_name": "acme.local",
|
||||
"m365_tenant_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"primary_contact": "John Doe",
|
||||
"notes": "Main MSP client",
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
client = client_service.create_client(db, client_data)
|
||||
return ClientResponse.model_validate(client)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{client_id}",
|
||||
response_model=ClientResponse,
|
||||
summary="Update client",
|
||||
description="Update an existing client's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Client updated successfully",
|
||||
"model": ClientResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Conflict with existing client",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with name 'Acme Corporation' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_client(
|
||||
client_id: UUID,
|
||||
client_data: ClientUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing client.
|
||||
|
||||
- **client_id**: UUID of the client to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/clients/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"primary_contact": "Jane Smith",
|
||||
"is_active": false,
|
||||
"notes": "Client moved to inactive status"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Acme Corporation",
|
||||
"type": "msp_client",
|
||||
"network_subnet": "192.168.0.0/24",
|
||||
"domain_name": "acme.local",
|
||||
"m365_tenant_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"primary_contact": "Jane Smith",
|
||||
"notes": "Client moved to inactive status",
|
||||
"is_active": false,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
client = client_service.update_client(db, client_id, client_data)
|
||||
return ClientResponse.model_validate(client)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Delete client",
|
||||
description="Delete a client by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Client deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Client deleted successfully",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_client(
|
||||
client_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a client.
|
||||
|
||||
- **client_id**: UUID of the client to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/clients/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Client deleted successfully",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return client_service.delete_client(db, client_id)
|
||||
312
api/routers/context_snippets.py
Normal file
312
api/routers/context_snippets.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
ContextSnippet API router for ClaudeTools.
|
||||
|
||||
Defines all REST API endpoints for managing context snippets,
|
||||
reusable pieces of knowledge for quick retrieval.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.context_snippet import (
|
||||
ContextSnippetCreate,
|
||||
ContextSnippetResponse,
|
||||
ContextSnippetUpdate,
|
||||
)
|
||||
from api.services import context_snippet_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all context snippets",
|
||||
description="Retrieve a paginated list of all context snippets with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_context_snippets(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all context snippets with pagination.
|
||||
|
||||
Returns snippets ordered by relevance score and usage count.
|
||||
"""
|
||||
try:
|
||||
snippets, total = context_snippet_service.get_context_snippets(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"snippets": [ContextSnippetResponse.model_validate(snippet) for snippet in snippets]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve context snippets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-tags",
|
||||
response_model=dict,
|
||||
summary="Get context snippets by tags",
|
||||
description="Retrieve context snippets filtered by tags",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_context_snippets_by_tags(
|
||||
tags: List[str] = Query(..., description="Tags to filter by (OR logic - any match)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get context snippets filtered by tags.
|
||||
|
||||
Uses OR logic - snippets matching any of the provided tags will be returned.
|
||||
"""
|
||||
try:
|
||||
snippets, total = context_snippet_service.get_context_snippets_by_tags(
|
||||
db, tags, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"tags": tags,
|
||||
"snippets": [ContextSnippetResponse.model_validate(snippet) for snippet in snippets]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve context snippets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/top-relevant",
|
||||
response_model=dict,
|
||||
summary="Get top relevant context snippets",
|
||||
description="Retrieve the most relevant context snippets by relevance score",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_top_relevant_snippets(
|
||||
limit: int = Query(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=50,
|
||||
description="Maximum number of snippets to retrieve (max 50)"
|
||||
),
|
||||
min_relevance_score: float = Query(
|
||||
default=7.0,
|
||||
ge=0.0,
|
||||
le=10.0,
|
||||
description="Minimum relevance score threshold (0.0-10.0)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get the top most relevant context snippets.
|
||||
|
||||
Returns snippets ordered by relevance score (highest first).
|
||||
"""
|
||||
try:
|
||||
snippets = context_snippet_service.get_top_relevant_snippets(
|
||||
db, limit, min_relevance_score
|
||||
)
|
||||
|
||||
return {
|
||||
"total": len(snippets),
|
||||
"limit": limit,
|
||||
"min_relevance_score": min_relevance_score,
|
||||
"snippets": [ContextSnippetResponse.model_validate(snippet) for snippet in snippets]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve top relevant snippets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-project/{project_id}",
|
||||
response_model=dict,
|
||||
summary="Get context snippets by project",
|
||||
description="Retrieve all context snippets for a specific project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_context_snippets_by_project(
|
||||
project_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all context snippets for a specific project.
|
||||
"""
|
||||
try:
|
||||
snippets, total = context_snippet_service.get_context_snippets_by_project(
|
||||
db, project_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"project_id": str(project_id),
|
||||
"snippets": [ContextSnippetResponse.model_validate(snippet) for snippet in snippets]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve context snippets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get context snippets by client",
|
||||
description="Retrieve all context snippets for a specific client",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_context_snippets_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all context snippets for a specific client.
|
||||
"""
|
||||
try:
|
||||
snippets, total = context_snippet_service.get_context_snippets_by_client(
|
||||
db, client_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"client_id": str(client_id),
|
||||
"snippets": [ContextSnippetResponse.model_validate(snippet) for snippet in snippets]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve context snippets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{snippet_id}",
|
||||
response_model=ContextSnippetResponse,
|
||||
summary="Get context snippet by ID",
|
||||
description="Retrieve a single context snippet by its unique identifier (increments usage_count)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_context_snippet(
|
||||
snippet_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific context snippet by ID.
|
||||
|
||||
Note: This automatically increments the usage_count for tracking.
|
||||
"""
|
||||
snippet = context_snippet_service.get_context_snippet_by_id(db, snippet_id)
|
||||
return ContextSnippetResponse.model_validate(snippet)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ContextSnippetResponse,
|
||||
summary="Create new context snippet",
|
||||
description="Create a new context snippet with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_context_snippet(
|
||||
snippet_data: ContextSnippetCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new context snippet.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
"""
|
||||
snippet = context_snippet_service.create_context_snippet(db, snippet_data)
|
||||
return ContextSnippetResponse.model_validate(snippet)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{snippet_id}",
|
||||
response_model=ContextSnippetResponse,
|
||||
summary="Update context snippet",
|
||||
description="Update an existing context snippet's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_context_snippet(
|
||||
snippet_id: UUID,
|
||||
snippet_data: ContextSnippetUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing context snippet.
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
"""
|
||||
snippet = context_snippet_service.update_context_snippet(db, snippet_id, snippet_data)
|
||||
return ContextSnippetResponse.model_validate(snippet)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{snippet_id}",
|
||||
response_model=dict,
|
||||
summary="Delete context snippet",
|
||||
description="Delete a context snippet by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_context_snippet(
|
||||
snippet_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a context snippet.
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
"""
|
||||
return context_snippet_service.delete_context_snippet(db, snippet_id)
|
||||
287
api/routers/conversation_contexts.py
Normal file
287
api/routers/conversation_contexts.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
ConversationContext API router for ClaudeTools.
|
||||
|
||||
Defines all REST API endpoints for managing conversation contexts,
|
||||
including context recall functionality for Claude's memory system.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.conversation_context import (
|
||||
ConversationContextCreate,
|
||||
ConversationContextResponse,
|
||||
ConversationContextUpdate,
|
||||
)
|
||||
from api.services import conversation_context_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all conversation contexts",
|
||||
description="Retrieve a paginated list of all conversation contexts with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_conversation_contexts(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all conversation contexts with pagination.
|
||||
|
||||
Returns contexts ordered by relevance score and recency.
|
||||
"""
|
||||
try:
|
||||
contexts, total = conversation_context_service.get_conversation_contexts(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"contexts": [ConversationContextResponse.model_validate(ctx) for ctx in contexts]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve conversation contexts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recall",
|
||||
response_model=dict,
|
||||
summary="Retrieve relevant contexts for injection",
|
||||
description="Get token-efficient context formatted for Claude prompt injection",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def recall_context(
|
||||
project_id: Optional[UUID] = Query(None, description="Filter by project ID"),
|
||||
tags: Optional[List[str]] = Query(None, description="Filter by tags (OR logic)"),
|
||||
limit: int = Query(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=50,
|
||||
description="Maximum number of contexts to retrieve (max 50)"
|
||||
),
|
||||
min_relevance_score: float = Query(
|
||||
default=5.0,
|
||||
ge=0.0,
|
||||
le=10.0,
|
||||
description="Minimum relevance score threshold (0.0-10.0)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Retrieve relevant contexts formatted for Claude prompt injection.
|
||||
|
||||
This endpoint returns a token-efficient markdown string ready for
|
||||
injection into Claude's prompt. It's the main context recall API.
|
||||
|
||||
Query Parameters:
|
||||
- project_id: Filter contexts by project
|
||||
- tags: Filter contexts by tags (any match)
|
||||
- limit: Maximum number of contexts to retrieve
|
||||
- min_relevance_score: Minimum relevance score threshold
|
||||
|
||||
Returns a formatted string ready for prompt injection.
|
||||
"""
|
||||
try:
|
||||
formatted_context = conversation_context_service.get_recall_context(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
tags=tags,
|
||||
limit=limit,
|
||||
min_relevance_score=min_relevance_score
|
||||
)
|
||||
|
||||
return {
|
||||
"context": formatted_context,
|
||||
"project_id": str(project_id) if project_id else None,
|
||||
"tags": tags,
|
||||
"limit": limit,
|
||||
"min_relevance_score": min_relevance_score
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve recall context: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-project/{project_id}",
|
||||
response_model=dict,
|
||||
summary="Get conversation contexts by project",
|
||||
description="Retrieve all conversation contexts for a specific project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_conversation_contexts_by_project(
|
||||
project_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all conversation contexts for a specific project.
|
||||
"""
|
||||
try:
|
||||
contexts, total = conversation_context_service.get_conversation_contexts_by_project(
|
||||
db, project_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"project_id": str(project_id),
|
||||
"contexts": [ConversationContextResponse.model_validate(ctx) for ctx in contexts]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve conversation contexts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-session/{session_id}",
|
||||
response_model=dict,
|
||||
summary="Get conversation contexts by session",
|
||||
description="Retrieve all conversation contexts for a specific session",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_conversation_contexts_by_session(
|
||||
session_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all conversation contexts for a specific session.
|
||||
"""
|
||||
try:
|
||||
contexts, total = conversation_context_service.get_conversation_contexts_by_session(
|
||||
db, session_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"session_id": str(session_id),
|
||||
"contexts": [ConversationContextResponse.model_validate(ctx) for ctx in contexts]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve conversation contexts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{context_id}",
|
||||
response_model=ConversationContextResponse,
|
||||
summary="Get conversation context by ID",
|
||||
description="Retrieve a single conversation context by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_conversation_context(
|
||||
context_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific conversation context by ID.
|
||||
"""
|
||||
context = conversation_context_service.get_conversation_context_by_id(db, context_id)
|
||||
return ConversationContextResponse.model_validate(context)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ConversationContextResponse,
|
||||
summary="Create new conversation context",
|
||||
description="Create a new conversation context with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_conversation_context(
|
||||
context_data: ConversationContextCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new conversation context.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
"""
|
||||
context = conversation_context_service.create_conversation_context(db, context_data)
|
||||
return ConversationContextResponse.model_validate(context)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{context_id}",
|
||||
response_model=ConversationContextResponse,
|
||||
summary="Update conversation context",
|
||||
description="Update an existing conversation context's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_conversation_context(
|
||||
context_id: UUID,
|
||||
context_data: ConversationContextUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing conversation context.
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
"""
|
||||
context = conversation_context_service.update_conversation_context(db, context_id, context_data)
|
||||
return ConversationContextResponse.model_validate(context)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{context_id}",
|
||||
response_model=dict,
|
||||
summary="Delete conversation context",
|
||||
description="Delete a conversation context by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_conversation_context(
|
||||
context_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a conversation context.
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
"""
|
||||
return conversation_context_service.delete_conversation_context(db, context_id)
|
||||
179
api/routers/credential_audit_logs.py
Normal file
179
api/routers/credential_audit_logs.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Credential Audit Logs API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for viewing credential audit logs (read-only).
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.credential_audit_log import CredentialAuditLogResponse
|
||||
from api.services import credential_audit_log_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all credential audit logs",
|
||||
description="Retrieve a paginated list of all credential audit log entries",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_credential_audit_logs(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all credential audit logs with pagination.
|
||||
|
||||
- **skip**: Number of logs to skip (default: 0)
|
||||
- **limit**: Maximum number of logs to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of audit log entries with pagination metadata.
|
||||
Logs are ordered by timestamp descending (most recent first).
|
||||
|
||||
**Note**: Audit logs are read-only and immutable.
|
||||
"""
|
||||
try:
|
||||
logs, total = credential_audit_log_service.get_credential_audit_logs(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"logs": [CredentialAuditLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve credential audit logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{log_id}",
|
||||
response_model=CredentialAuditLogResponse,
|
||||
summary="Get credential audit log by ID",
|
||||
description="Retrieve a single credential audit log entry by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_credential_audit_log(
|
||||
log_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific credential audit log entry by ID.
|
||||
|
||||
- **log_id**: UUID of the audit log entry to retrieve
|
||||
|
||||
Returns the complete audit log details including action, user, timestamp, and context.
|
||||
"""
|
||||
log = credential_audit_log_service.get_credential_audit_log_by_id(db, log_id)
|
||||
return CredentialAuditLogResponse.model_validate(log)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-credential/{credential_id}",
|
||||
response_model=dict,
|
||||
summary="Get audit logs for a credential",
|
||||
description="Retrieve all audit log entries for a specific credential",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_credential_audit_logs_by_credential(
|
||||
credential_id: UUID,
|
||||
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all audit log entries for a specific credential.
|
||||
|
||||
- **credential_id**: UUID of the credential
|
||||
- **skip**: Number of logs to skip (default: 0)
|
||||
- **limit**: Maximum number of logs to return (default: 100, max: 1000)
|
||||
|
||||
Returns all operations performed on this credential including views, updates,
|
||||
and deletions. Logs are ordered by timestamp descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
logs, total = credential_audit_log_service.get_credential_audit_logs_by_credential(
|
||||
db, credential_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"credential_id": str(credential_id),
|
||||
"logs": [CredentialAuditLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve credential audit logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-user/{user_id}",
|
||||
response_model=dict,
|
||||
summary="Get audit logs for a user",
|
||||
description="Retrieve all audit log entries for a specific user",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_credential_audit_logs_by_user(
|
||||
user_id: str,
|
||||
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all audit log entries for a specific user.
|
||||
|
||||
- **user_id**: User ID to filter by (JWT sub claim)
|
||||
- **skip**: Number of logs to skip (default: 0)
|
||||
- **limit**: Maximum number of logs to return (default: 100, max: 1000)
|
||||
|
||||
Returns all credential operations performed by this user.
|
||||
Logs are ordered by timestamp descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
logs, total = credential_audit_log_service.get_credential_audit_logs_by_user(
|
||||
db, user_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"user_id": user_id,
|
||||
"logs": [CredentialAuditLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve credential audit logs: {str(e)}"
|
||||
)
|
||||
429
api/routers/credentials.py
Normal file
429
api/routers/credentials.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Credentials API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing credentials with encryption.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.credential import (
|
||||
CredentialCreate,
|
||||
CredentialResponse,
|
||||
CredentialUpdate,
|
||||
)
|
||||
from api.services import credential_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_user_context(request: Request, current_user: dict) -> dict:
|
||||
"""Extract user context for audit logging."""
|
||||
return {
|
||||
"user_id": current_user.get("sub", "unknown"),
|
||||
"ip_address": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all credentials",
|
||||
description="Retrieve a paginated list of all credentials (decrypted for authenticated users)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_credentials(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all credentials with pagination.
|
||||
|
||||
- **skip**: Number of credentials to skip (default: 0)
|
||||
- **limit**: Maximum number of credentials to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of credentials with pagination metadata.
|
||||
Sensitive fields are decrypted and returned to authenticated users.
|
||||
|
||||
**Security Note**: This endpoint returns decrypted passwords and keys.
|
||||
Ensure proper authentication and authorization before calling.
|
||||
"""
|
||||
try:
|
||||
credentials, total = credential_service.get_credentials(db, skip, limit)
|
||||
|
||||
# Convert to response models with decryption
|
||||
response_credentials = []
|
||||
for cred in credentials:
|
||||
# Map encrypted fields to decrypted field names for the response schema
|
||||
cred_dict = {
|
||||
"id": cred.id,
|
||||
"client_id": cred.client_id,
|
||||
"service_id": cred.service_id,
|
||||
"infrastructure_id": cred.infrastructure_id,
|
||||
"credential_type": cred.credential_type,
|
||||
"service_name": cred.service_name,
|
||||
"username": cred.username,
|
||||
"password": cred.password_encrypted, # Will be decrypted by validator
|
||||
"api_key": cred.api_key_encrypted, # Will be decrypted by validator
|
||||
"client_id_oauth": cred.client_id_oauth,
|
||||
"client_secret": cred.client_secret_encrypted, # Will be decrypted by validator
|
||||
"tenant_id_oauth": cred.tenant_id_oauth,
|
||||
"public_key": cred.public_key,
|
||||
"token": cred.token_encrypted, # Will be decrypted by validator
|
||||
"connection_string": cred.connection_string_encrypted, # Will be decrypted by validator
|
||||
"integration_code": cred.integration_code,
|
||||
"external_url": cred.external_url,
|
||||
"internal_url": cred.internal_url,
|
||||
"custom_port": cred.custom_port,
|
||||
"role_description": cred.role_description,
|
||||
"requires_vpn": cred.requires_vpn,
|
||||
"requires_2fa": cred.requires_2fa,
|
||||
"ssh_key_auth_enabled": cred.ssh_key_auth_enabled,
|
||||
"access_level": cred.access_level,
|
||||
"expires_at": cred.expires_at,
|
||||
"last_rotated_at": cred.last_rotated_at,
|
||||
"is_active": cred.is_active,
|
||||
"created_at": cred.created_at,
|
||||
"updated_at": cred.updated_at,
|
||||
}
|
||||
response_credentials.append(CredentialResponse(**cred_dict))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"credentials": response_credentials
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve credentials: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{credential_id}",
|
||||
response_model=CredentialResponse,
|
||||
summary="Get credential by ID",
|
||||
description="Retrieve a single credential by its unique identifier (decrypted)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_credential(
|
||||
credential_id: UUID,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific credential by ID.
|
||||
|
||||
- **credential_id**: UUID of the credential to retrieve
|
||||
|
||||
Returns the complete credential details with decrypted sensitive fields.
|
||||
This action is logged in the audit trail.
|
||||
|
||||
**Security Note**: This endpoint returns decrypted passwords and keys.
|
||||
"""
|
||||
user_ctx = _get_user_context(request, current_user)
|
||||
credential = credential_service.get_credential_by_id(db, credential_id, user_id=user_ctx["user_id"])
|
||||
|
||||
# Map encrypted fields to decrypted field names
|
||||
cred_dict = {
|
||||
"id": credential.id,
|
||||
"client_id": credential.client_id,
|
||||
"service_id": credential.service_id,
|
||||
"infrastructure_id": credential.infrastructure_id,
|
||||
"credential_type": credential.credential_type,
|
||||
"service_name": credential.service_name,
|
||||
"username": credential.username,
|
||||
"password": credential.password_encrypted,
|
||||
"api_key": credential.api_key_encrypted,
|
||||
"client_id_oauth": credential.client_id_oauth,
|
||||
"client_secret": credential.client_secret_encrypted,
|
||||
"tenant_id_oauth": credential.tenant_id_oauth,
|
||||
"public_key": credential.public_key,
|
||||
"token": credential.token_encrypted,
|
||||
"connection_string": credential.connection_string_encrypted,
|
||||
"integration_code": credential.integration_code,
|
||||
"external_url": credential.external_url,
|
||||
"internal_url": credential.internal_url,
|
||||
"custom_port": credential.custom_port,
|
||||
"role_description": credential.role_description,
|
||||
"requires_vpn": credential.requires_vpn,
|
||||
"requires_2fa": credential.requires_2fa,
|
||||
"ssh_key_auth_enabled": credential.ssh_key_auth_enabled,
|
||||
"access_level": credential.access_level,
|
||||
"expires_at": credential.expires_at,
|
||||
"last_rotated_at": credential.last_rotated_at,
|
||||
"is_active": credential.is_active,
|
||||
"created_at": credential.created_at,
|
||||
"updated_at": credential.updated_at,
|
||||
}
|
||||
|
||||
return CredentialResponse(**cred_dict)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CredentialResponse,
|
||||
summary="Create new credential",
|
||||
description="Create a new credential with encryption of sensitive fields",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_credential(
|
||||
credential_data: CredentialCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new credential.
|
||||
|
||||
Sensitive fields (password, api_key, client_secret, token, connection_string)
|
||||
are automatically encrypted before storage. This action is logged in the audit trail.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Security Note**: Plaintext credentials are never logged or stored unencrypted.
|
||||
"""
|
||||
user_ctx = _get_user_context(request, current_user)
|
||||
credential = credential_service.create_credential(
|
||||
db,
|
||||
credential_data,
|
||||
user_id=user_ctx["user_id"],
|
||||
ip_address=user_ctx["ip_address"],
|
||||
user_agent=user_ctx["user_agent"],
|
||||
)
|
||||
|
||||
# Map encrypted fields to decrypted field names
|
||||
cred_dict = {
|
||||
"id": credential.id,
|
||||
"client_id": credential.client_id,
|
||||
"service_id": credential.service_id,
|
||||
"infrastructure_id": credential.infrastructure_id,
|
||||
"credential_type": credential.credential_type,
|
||||
"service_name": credential.service_name,
|
||||
"username": credential.username,
|
||||
"password": credential.password_encrypted,
|
||||
"api_key": credential.api_key_encrypted,
|
||||
"client_id_oauth": credential.client_id_oauth,
|
||||
"client_secret": credential.client_secret_encrypted,
|
||||
"tenant_id_oauth": credential.tenant_id_oauth,
|
||||
"public_key": credential.public_key,
|
||||
"token": credential.token_encrypted,
|
||||
"connection_string": credential.connection_string_encrypted,
|
||||
"integration_code": credential.integration_code,
|
||||
"external_url": credential.external_url,
|
||||
"internal_url": credential.internal_url,
|
||||
"custom_port": credential.custom_port,
|
||||
"role_description": credential.role_description,
|
||||
"requires_vpn": credential.requires_vpn,
|
||||
"requires_2fa": credential.requires_2fa,
|
||||
"ssh_key_auth_enabled": credential.ssh_key_auth_enabled,
|
||||
"access_level": credential.access_level,
|
||||
"expires_at": credential.expires_at,
|
||||
"last_rotated_at": credential.last_rotated_at,
|
||||
"is_active": credential.is_active,
|
||||
"created_at": credential.created_at,
|
||||
"updated_at": credential.updated_at,
|
||||
}
|
||||
|
||||
return CredentialResponse(**cred_dict)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{credential_id}",
|
||||
response_model=CredentialResponse,
|
||||
summary="Update credential",
|
||||
description="Update an existing credential's details with re-encryption if needed",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_credential(
|
||||
credential_id: UUID,
|
||||
credential_data: CredentialUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing credential.
|
||||
|
||||
- **credential_id**: UUID of the credential to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If sensitive fields are updated, they are re-encrypted. This action is logged.
|
||||
|
||||
**Security Note**: Updated credentials are re-encrypted before storage.
|
||||
"""
|
||||
user_ctx = _get_user_context(request, current_user)
|
||||
credential = credential_service.update_credential(
|
||||
db,
|
||||
credential_id,
|
||||
credential_data,
|
||||
user_id=user_ctx["user_id"],
|
||||
ip_address=user_ctx["ip_address"],
|
||||
user_agent=user_ctx["user_agent"],
|
||||
)
|
||||
|
||||
# Map encrypted fields to decrypted field names
|
||||
cred_dict = {
|
||||
"id": credential.id,
|
||||
"client_id": credential.client_id,
|
||||
"service_id": credential.service_id,
|
||||
"infrastructure_id": credential.infrastructure_id,
|
||||
"credential_type": credential.credential_type,
|
||||
"service_name": credential.service_name,
|
||||
"username": credential.username,
|
||||
"password": credential.password_encrypted,
|
||||
"api_key": credential.api_key_encrypted,
|
||||
"client_id_oauth": credential.client_id_oauth,
|
||||
"client_secret": credential.client_secret_encrypted,
|
||||
"tenant_id_oauth": credential.tenant_id_oauth,
|
||||
"public_key": credential.public_key,
|
||||
"token": credential.token_encrypted,
|
||||
"connection_string": credential.connection_string_encrypted,
|
||||
"integration_code": credential.integration_code,
|
||||
"external_url": credential.external_url,
|
||||
"internal_url": credential.internal_url,
|
||||
"custom_port": credential.custom_port,
|
||||
"role_description": credential.role_description,
|
||||
"requires_vpn": credential.requires_vpn,
|
||||
"requires_2fa": credential.requires_2fa,
|
||||
"ssh_key_auth_enabled": credential.ssh_key_auth_enabled,
|
||||
"access_level": credential.access_level,
|
||||
"expires_at": credential.expires_at,
|
||||
"last_rotated_at": credential.last_rotated_at,
|
||||
"is_active": credential.is_active,
|
||||
"created_at": credential.created_at,
|
||||
"updated_at": credential.updated_at,
|
||||
}
|
||||
|
||||
return CredentialResponse(**cred_dict)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}",
|
||||
response_model=dict,
|
||||
summary="Delete credential",
|
||||
description="Delete a credential by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_credential(
|
||||
credential_id: UUID,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a credential.
|
||||
|
||||
- **credential_id**: UUID of the credential to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
The deletion is logged in the audit trail.
|
||||
|
||||
**Security Note**: Audit logs are retained after credential deletion.
|
||||
"""
|
||||
user_ctx = _get_user_context(request, current_user)
|
||||
return credential_service.delete_credential(
|
||||
db,
|
||||
credential_id,
|
||||
user_id=user_ctx["user_id"],
|
||||
ip_address=user_ctx["ip_address"],
|
||||
user_agent=user_ctx["user_agent"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get credentials by client",
|
||||
description="Retrieve all credentials for a specific client",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_credentials_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all credentials associated with a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of credentials to skip (default: 0)
|
||||
- **limit**: Maximum number of credentials to return (default: 100, max: 1000)
|
||||
|
||||
Returns credentials with decrypted sensitive fields.
|
||||
"""
|
||||
try:
|
||||
credentials, total = credential_service.get_credentials_by_client(db, client_id, skip, limit)
|
||||
|
||||
# Convert to response models with decryption
|
||||
response_credentials = []
|
||||
for cred in credentials:
|
||||
cred_dict = {
|
||||
"id": cred.id,
|
||||
"client_id": cred.client_id,
|
||||
"service_id": cred.service_id,
|
||||
"infrastructure_id": cred.infrastructure_id,
|
||||
"credential_type": cred.credential_type,
|
||||
"service_name": cred.service_name,
|
||||
"username": cred.username,
|
||||
"password": cred.password_encrypted,
|
||||
"api_key": cred.api_key_encrypted,
|
||||
"client_id_oauth": cred.client_id_oauth,
|
||||
"client_secret": cred.client_secret_encrypted,
|
||||
"tenant_id_oauth": cred.tenant_id_oauth,
|
||||
"public_key": cred.public_key,
|
||||
"token": cred.token_encrypted,
|
||||
"connection_string": cred.connection_string_encrypted,
|
||||
"integration_code": cred.integration_code,
|
||||
"external_url": cred.external_url,
|
||||
"internal_url": cred.internal_url,
|
||||
"custom_port": cred.custom_port,
|
||||
"role_description": cred.role_description,
|
||||
"requires_vpn": cred.requires_vpn,
|
||||
"requires_2fa": cred.requires_2fa,
|
||||
"ssh_key_auth_enabled": cred.ssh_key_auth_enabled,
|
||||
"access_level": cred.access_level,
|
||||
"expires_at": cred.expires_at,
|
||||
"last_rotated_at": cred.last_rotated_at,
|
||||
"is_active": cred.is_active,
|
||||
"created_at": cred.created_at,
|
||||
"updated_at": cred.updated_at,
|
||||
}
|
||||
response_credentials.append(CredentialResponse(**cred_dict))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"client_id": str(client_id),
|
||||
"credentials": response_credentials
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve credentials for client: {str(e)}"
|
||||
)
|
||||
264
api/routers/decision_logs.py
Normal file
264
api/routers/decision_logs.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
DecisionLog API router for ClaudeTools.
|
||||
|
||||
Defines all REST API endpoints for managing decision logs,
|
||||
tracking important decisions made during work.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.decision_log import (
|
||||
DecisionLogCreate,
|
||||
DecisionLogResponse,
|
||||
DecisionLogUpdate,
|
||||
)
|
||||
from api.services import decision_log_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all decision logs",
|
||||
description="Retrieve a paginated list of all decision logs",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_decision_logs(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all decision logs with pagination.
|
||||
|
||||
Returns decision logs ordered by most recent first.
|
||||
"""
|
||||
try:
|
||||
logs, total = decision_log_service.get_decision_logs(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"logs": [DecisionLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve decision logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-impact/{impact}",
|
||||
response_model=dict,
|
||||
summary="Get decision logs by impact level",
|
||||
description="Retrieve decision logs filtered by impact level",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_decision_logs_by_impact(
|
||||
impact: str,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get decision logs filtered by impact level.
|
||||
|
||||
Valid impact levels: low, medium, high, critical
|
||||
"""
|
||||
try:
|
||||
logs, total = decision_log_service.get_decision_logs_by_impact(
|
||||
db, impact, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"impact": impact,
|
||||
"logs": [DecisionLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve decision logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-project/{project_id}",
|
||||
response_model=dict,
|
||||
summary="Get decision logs by project",
|
||||
description="Retrieve all decision logs for a specific project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_decision_logs_by_project(
|
||||
project_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all decision logs for a specific project.
|
||||
"""
|
||||
try:
|
||||
logs, total = decision_log_service.get_decision_logs_by_project(
|
||||
db, project_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"project_id": str(project_id),
|
||||
"logs": [DecisionLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve decision logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-session/{session_id}",
|
||||
response_model=dict,
|
||||
summary="Get decision logs by session",
|
||||
description="Retrieve all decision logs for a specific session",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_decision_logs_by_session(
|
||||
session_id: UUID,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all decision logs for a specific session.
|
||||
"""
|
||||
try:
|
||||
logs, total = decision_log_service.get_decision_logs_by_session(
|
||||
db, session_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"session_id": str(session_id),
|
||||
"logs": [DecisionLogResponse.model_validate(log) for log in logs]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve decision logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{log_id}",
|
||||
response_model=DecisionLogResponse,
|
||||
summary="Get decision log by ID",
|
||||
description="Retrieve a single decision log by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_decision_log(
|
||||
log_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific decision log by ID.
|
||||
"""
|
||||
log = decision_log_service.get_decision_log_by_id(db, log_id)
|
||||
return DecisionLogResponse.model_validate(log)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=DecisionLogResponse,
|
||||
summary="Create new decision log",
|
||||
description="Create a new decision log with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_decision_log(
|
||||
log_data: DecisionLogCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new decision log.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
"""
|
||||
log = decision_log_service.create_decision_log(db, log_data)
|
||||
return DecisionLogResponse.model_validate(log)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{log_id}",
|
||||
response_model=DecisionLogResponse,
|
||||
summary="Update decision log",
|
||||
description="Update an existing decision log's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_decision_log(
|
||||
log_id: UUID,
|
||||
log_data: DecisionLogUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing decision log.
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
"""
|
||||
log = decision_log_service.update_decision_log(db, log_id, log_data)
|
||||
return DecisionLogResponse.model_validate(log)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{log_id}",
|
||||
response_model=dict,
|
||||
summary="Delete decision log",
|
||||
description="Delete a decision log by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_decision_log(
|
||||
log_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a decision log.
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
"""
|
||||
return decision_log_service.delete_decision_log(db, log_id)
|
||||
469
api/routers/firewall_rules.py
Normal file
469
api/routers/firewall_rules.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Firewall Rule API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing firewall rules, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.firewall_rule import (
|
||||
FirewallRuleCreate,
|
||||
FirewallRuleResponse,
|
||||
FirewallRuleUpdate,
|
||||
)
|
||||
from api.services import firewall_rule_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all firewall rules",
|
||||
description="Retrieve a paginated list of all firewall rules with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_firewall_rules(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all firewall rules with pagination.
|
||||
|
||||
- **skip**: Number of firewall rules to skip (default: 0)
|
||||
- **limit**: Maximum number of firewall rules to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of firewall rules with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/firewall-rules?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"firewall_rules": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "allow",
|
||||
"rule_order": 1,
|
||||
"notes": "Allow SSH from internal network",
|
||||
"created_by": "admin@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
rules, total = firewall_rule_service.get_firewall_rules(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"firewall_rules": [FirewallRuleResponse.model_validate(rule) for rule in rules]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve firewall rules: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-infrastructure/{infrastructure_id}",
|
||||
response_model=dict,
|
||||
summary="Get firewall rules by infrastructure",
|
||||
description="Retrieve all firewall rules for a specific infrastructure with pagination",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Firewall rules found and returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"firewall_rules": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "allow",
|
||||
"rule_order": 1,
|
||||
"notes": "Allow SSH from internal network",
|
||||
"created_by": "admin@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID abc12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_firewall_rules_by_infrastructure(
|
||||
infrastructure_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all firewall rules for a specific infrastructure.
|
||||
|
||||
- **infrastructure_id**: UUID of the infrastructure
|
||||
- **skip**: Number of firewall rules to skip (default: 0)
|
||||
- **limit**: Maximum number of firewall rules to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of firewall rules for the specified infrastructure with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/firewall-rules/by-infrastructure/abc12345-6789-0def-1234-56789abcdef0?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
"""
|
||||
rules, total = firewall_rule_service.get_firewall_rules_by_infrastructure(db, infrastructure_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"firewall_rules": [FirewallRuleResponse.model_validate(rule) for rule in rules]
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{firewall_rule_id}",
|
||||
response_model=FirewallRuleResponse,
|
||||
summary="Get firewall rule by ID",
|
||||
description="Retrieve a single firewall rule by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Firewall rule found and returned",
|
||||
"model": FirewallRuleResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Firewall rule not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Firewall rule with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_firewall_rule(
|
||||
firewall_rule_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific firewall rule by ID.
|
||||
|
||||
- **firewall_rule_id**: UUID of the firewall rule to retrieve
|
||||
|
||||
Returns the complete firewall rule details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/firewall-rules/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "allow",
|
||||
"rule_order": 1,
|
||||
"notes": "Allow SSH from internal network",
|
||||
"created_by": "admin@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
rule = firewall_rule_service.get_firewall_rule_by_id(db, firewall_rule_id)
|
||||
return FirewallRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=FirewallRuleResponse,
|
||||
summary="Create new firewall rule",
|
||||
description="Create a new firewall rule with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Firewall rule created successfully",
|
||||
"model": FirewallRuleResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID abc12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "rule_name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_firewall_rule(
|
||||
firewall_rule_data: FirewallRuleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new firewall rule.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The infrastructure_id must reference an existing infrastructure if provided.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/firewall-rules
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "allow",
|
||||
"rule_order": 1,
|
||||
"notes": "Allow SSH from internal network",
|
||||
"created_by": "admin@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "allow",
|
||||
"rule_order": 1,
|
||||
"notes": "Allow SSH from internal network",
|
||||
"created_by": "admin@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
rule = firewall_rule_service.create_firewall_rule(db, firewall_rule_data)
|
||||
return FirewallRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{firewall_rule_id}",
|
||||
response_model=FirewallRuleResponse,
|
||||
summary="Update firewall rule",
|
||||
description="Update an existing firewall rule's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Firewall rule updated successfully",
|
||||
"model": FirewallRuleResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Firewall rule or infrastructure not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Firewall rule with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_firewall_rule(
|
||||
firewall_rule_id: UUID,
|
||||
firewall_rule_data: FirewallRuleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing firewall rule.
|
||||
|
||||
- **firewall_rule_id**: UUID of the firewall rule to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating infrastructure_id, the new infrastructure must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/firewall-rules/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "deny",
|
||||
"notes": "Changed to deny SSH access"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"rule_name": "Allow SSH",
|
||||
"source_cidr": "10.0.0.0/8",
|
||||
"destination_cidr": "192.168.1.0/24",
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"action": "deny",
|
||||
"rule_order": 1,
|
||||
"notes": "Changed to deny SSH access",
|
||||
"created_by": "admin@example.com",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
rule = firewall_rule_service.update_firewall_rule(db, firewall_rule_id, firewall_rule_data)
|
||||
return FirewallRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{firewall_rule_id}",
|
||||
response_model=dict,
|
||||
summary="Delete firewall rule",
|
||||
description="Delete a firewall rule by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Firewall rule deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Firewall rule deleted successfully",
|
||||
"firewall_rule_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Firewall rule not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Firewall rule with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_firewall_rule(
|
||||
firewall_rule_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a firewall rule.
|
||||
|
||||
- **firewall_rule_id**: UUID of the firewall rule to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/firewall-rules/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Firewall rule deleted successfully",
|
||||
"firewall_rule_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return firewall_rule_service.delete_firewall_rule(db, firewall_rule_id)
|
||||
556
api/routers/infrastructure.py
Normal file
556
api/routers/infrastructure.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Infrastructure API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing infrastructure assets,
|
||||
including CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.infrastructure import (
|
||||
InfrastructureCreate,
|
||||
InfrastructureResponse,
|
||||
InfrastructureUpdate,
|
||||
)
|
||||
from api.services import infrastructure_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all infrastructure items",
|
||||
description="Retrieve a paginated list of all infrastructure items with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_infrastructure(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all infrastructure items with pagination.
|
||||
|
||||
- **skip**: Number of items to skip (default: 0)
|
||||
- **limit**: Maximum number of items to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of infrastructure items with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/infrastructure?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 10,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"infrastructure": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"client_id": "client-uuid",
|
||||
"site_id": "site-uuid",
|
||||
"status": "active",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
items, total = infrastructure_service.get_infrastructure_items(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"infrastructure": [InfrastructureResponse.model_validate(item) for item in items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve infrastructure items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{infrastructure_id}",
|
||||
response_model=InfrastructureResponse,
|
||||
summary="Get infrastructure by ID",
|
||||
description="Retrieve a single infrastructure item by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Infrastructure item found and returned",
|
||||
"model": InfrastructureResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_infrastructure(
|
||||
infrastructure_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific infrastructure item by ID.
|
||||
|
||||
- **infrastructure_id**: UUID of the infrastructure item to retrieve
|
||||
|
||||
Returns the complete infrastructure item details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/infrastructure/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"client_id": "client-uuid",
|
||||
"site_id": "site-uuid",
|
||||
"ip_address": "192.168.1.10",
|
||||
"mac_address": "00:1A:2B:3C:4D:5E",
|
||||
"os": "Windows Server 2022",
|
||||
"os_version": "21H2",
|
||||
"role_description": "Primary domain controller for the network",
|
||||
"status": "active",
|
||||
"has_gui": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
item = infrastructure_service.get_infrastructure_by_id(db, infrastructure_id)
|
||||
return InfrastructureResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=InfrastructureResponse,
|
||||
summary="Create new infrastructure item",
|
||||
description="Create a new infrastructure item with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Infrastructure item created successfully",
|
||||
"model": InfrastructureResponse,
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error or invalid foreign key",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "hostname"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_infrastructure(
|
||||
infrastructure_data: InfrastructureCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new infrastructure item.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
Validates foreign keys (client_id, site_id, parent_host_id) before creation.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/infrastructure
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"client_id": "client-uuid",
|
||||
"site_id": "site-uuid",
|
||||
"ip_address": "192.168.1.10",
|
||||
"mac_address": "00:1A:2B:3C:4D:5E",
|
||||
"os": "Windows Server 2022",
|
||||
"os_version": "21H2",
|
||||
"role_description": "Primary domain controller",
|
||||
"status": "active",
|
||||
"powershell_version": "5.1",
|
||||
"shell_type": "powershell",
|
||||
"has_gui": true
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"status": "active",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
item = infrastructure_service.create_infrastructure(db, infrastructure_data)
|
||||
return InfrastructureResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{infrastructure_id}",
|
||||
response_model=InfrastructureResponse,
|
||||
summary="Update infrastructure item",
|
||||
description="Update an existing infrastructure item's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Infrastructure item updated successfully",
|
||||
"model": InfrastructureResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error or invalid foreign key",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID client-uuid not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_infrastructure(
|
||||
infrastructure_id: UUID,
|
||||
infrastructure_data: InfrastructureUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing infrastructure item.
|
||||
|
||||
- **infrastructure_id**: UUID of the infrastructure item to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
Validates foreign keys (client_id, site_id, parent_host_id) before updating.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/infrastructure/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "decommissioned",
|
||||
"notes": "Server retired and replaced with new hardware"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"status": "decommissioned",
|
||||
"notes": "Server retired and replaced with new hardware",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
item = infrastructure_service.update_infrastructure(db, infrastructure_id, infrastructure_data)
|
||||
return InfrastructureResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{infrastructure_id}",
|
||||
response_model=dict,
|
||||
summary="Delete infrastructure item",
|
||||
description="Delete an infrastructure item by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Infrastructure item deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Infrastructure deleted successfully",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_infrastructure(
|
||||
infrastructure_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete an infrastructure item.
|
||||
|
||||
- **infrastructure_id**: UUID of the infrastructure item to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/infrastructure/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Infrastructure deleted successfully",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return infrastructure_service.delete_infrastructure(db, infrastructure_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-site/{site_id}",
|
||||
response_model=dict,
|
||||
summary="Get infrastructure by site",
|
||||
description="Retrieve all infrastructure items for a specific site",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Infrastructure items for site returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"infrastructure": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_infrastructure_by_site(
|
||||
site_id: str,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all infrastructure items for a specific site.
|
||||
|
||||
- **site_id**: UUID of the site
|
||||
- **skip**: Number of items to skip (default: 0)
|
||||
- **limit**: Maximum number of items to return (default: 100, max: 1000)
|
||||
|
||||
Returns infrastructure items associated with the specified site.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/infrastructure/by-site/site-uuid-here?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"infrastructure": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"site_id": "site-uuid-here",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
items, total = infrastructure_service.get_infrastructure_by_site(db, site_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"infrastructure": [InfrastructureResponse.model_validate(item) for item in items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve infrastructure items for site: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get infrastructure by client",
|
||||
description="Retrieve all infrastructure items for a specific client",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Infrastructure items for client returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"infrastructure": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_infrastructure_by_client(
|
||||
client_id: str,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all infrastructure items for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of items to skip (default: 0)
|
||||
- **limit**: Maximum number of items to return (default: 100, max: 1000)
|
||||
|
||||
Returns infrastructure items associated with the specified client.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/infrastructure/by-client/client-uuid-here?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"infrastructure": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "server-dc-01",
|
||||
"asset_type": "domain_controller",
|
||||
"client_id": "client-uuid-here",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
items, total = infrastructure_service.get_infrastructure_by_client(db, client_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"infrastructure": [InfrastructureResponse.model_validate(item) for item in items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve infrastructure items for client: {str(e)}"
|
||||
)
|
||||
467
api/routers/m365_tenants.py
Normal file
467
api/routers/m365_tenants.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
M365 Tenant API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing M365 tenants, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.m365_tenant import (
|
||||
M365TenantCreate,
|
||||
M365TenantResponse,
|
||||
M365TenantUpdate,
|
||||
)
|
||||
from api.services import m365_tenant_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all M365 tenants",
|
||||
description="Retrieve a paginated list of all M365 tenants with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_m365_tenants(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all M365 tenants with pagination.
|
||||
|
||||
- **skip**: Number of M365 tenants to skip (default: 0)
|
||||
- **limit**: Maximum number of M365 tenants to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of M365 tenants with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/m365-tenants?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 3,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"m365_tenants": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "admin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Primary M365 tenant",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
tenants, total = m365_tenant_service.get_m365_tenants(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"m365_tenants": [M365TenantResponse.model_validate(tenant) for tenant in tenants]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve M365 tenants: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}",
|
||||
response_model=M365TenantResponse,
|
||||
summary="Get M365 tenant by ID",
|
||||
description="Retrieve a single M365 tenant by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "M365 tenant found and returned",
|
||||
"model": M365TenantResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "M365 tenant not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "M365 tenant with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_m365_tenant(
|
||||
tenant_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific M365 tenant by ID.
|
||||
|
||||
- **tenant_id**: UUID of the M365 tenant to retrieve
|
||||
|
||||
Returns the complete M365 tenant details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/m365-tenants/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "admin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Primary M365 tenant",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tenant = m365_tenant_service.get_m365_tenant_by_id(db, tenant_id)
|
||||
return M365TenantResponse.model_validate(tenant)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get M365 tenants by client",
|
||||
description="Retrieve all M365 tenants for a specific client",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "M365 tenants found and returned",
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_m365_tenants_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all M365 tenants for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of M365 tenants to skip (default: 0)
|
||||
- **limit**: Maximum number of M365 tenants to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of M365 tenants for the specified client.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/m365-tenants/by-client/abc12345-6789-0def-1234-56789abcdef0?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 2,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"m365_tenants": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "admin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Primary M365 tenant",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
tenants, total = m365_tenant_service.get_m365_tenants_by_client(db, client_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"client_id": str(client_id),
|
||||
"m365_tenants": [M365TenantResponse.model_validate(tenant) for tenant in tenants]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=M365TenantResponse,
|
||||
summary="Create new M365 tenant",
|
||||
description="Create a new M365 tenant with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "M365 tenant created successfully",
|
||||
"model": M365TenantResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID abc12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "M365 tenant with tenant_id already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "M365 tenant with tenant_id 'def45678-9abc-0123-4567-89abcdef0123' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "tenant_id"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_m365_tenant(
|
||||
tenant_data: M365TenantCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new M365 tenant.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/m365-tenants
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "admin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Primary M365 tenant"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "admin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Primary M365 tenant",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tenant = m365_tenant_service.create_m365_tenant(db, tenant_data)
|
||||
return M365TenantResponse.model_validate(tenant)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}",
|
||||
response_model=M365TenantResponse,
|
||||
summary="Update M365 tenant",
|
||||
description="Update an existing M365 tenant's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "M365 tenant updated successfully",
|
||||
"model": M365TenantResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "M365 tenant or client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "M365 tenant with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Conflict with existing M365 tenant",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "M365 tenant with tenant_id 'def45678-9abc-0123-4567-89abcdef0123' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_m365_tenant(
|
||||
tenant_id: UUID,
|
||||
tenant_data: M365TenantUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing M365 tenant.
|
||||
|
||||
- **tenant_id**: UUID of the M365 tenant to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/m365-tenants/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"admin_email": "newadmin@dataforth.com",
|
||||
"notes": "Updated administrator contact"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"tenant_id": "def45678-9abc-0123-4567-89abcdef0123",
|
||||
"tenant_name": "dataforth.com",
|
||||
"default_domain": "dataforthcorp.onmicrosoft.com",
|
||||
"admin_email": "newadmin@dataforth.com",
|
||||
"cipp_name": "Dataforth Corp",
|
||||
"notes": "Updated administrator contact",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tenant = m365_tenant_service.update_m365_tenant(db, tenant_id, tenant_data)
|
||||
return M365TenantResponse.model_validate(tenant)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}",
|
||||
response_model=dict,
|
||||
summary="Delete M365 tenant",
|
||||
description="Delete an M365 tenant by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "M365 tenant deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "M365 tenant deleted successfully",
|
||||
"tenant_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "M365 tenant not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "M365 tenant with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_m365_tenant(
|
||||
tenant_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete an M365 tenant.
|
||||
|
||||
- **tenant_id**: UUID of the M365 tenant to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/m365-tenants/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "M365 tenant deleted successfully",
|
||||
"tenant_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return m365_tenant_service.delete_m365_tenant(db, tenant_id)
|
||||
457
api/routers/machines.py
Normal file
457
api/routers/machines.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Machine API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing machines, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.machine import (
|
||||
MachineCreate,
|
||||
MachineResponse,
|
||||
MachineUpdate,
|
||||
)
|
||||
from api.services import machine_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all machines",
|
||||
description="Retrieve a paginated list of all machines with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_machines(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
active_only: bool = Query(
|
||||
default=False,
|
||||
description="If true, only return active machines"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all machines with pagination.
|
||||
|
||||
- **skip**: Number of machines to skip (default: 0)
|
||||
- **limit**: Maximum number of machines to return (default: 100, max: 1000)
|
||||
- **active_only**: Filter to only active machines (default: false)
|
||||
|
||||
Returns a list of machines with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/machines?skip=0&limit=50&active_only=true
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"machines": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Main Development Laptop",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"is_primary": true,
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
if active_only:
|
||||
machines, total = machine_service.get_active_machines(db, skip, limit)
|
||||
else:
|
||||
machines, total = machine_service.get_machines(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"machines": [MachineResponse.model_validate(machine) for machine in machines]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve machines: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{machine_id}",
|
||||
response_model=MachineResponse,
|
||||
summary="Get machine by ID",
|
||||
description="Retrieve a single machine by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Machine found and returned",
|
||||
"model": MachineResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Machine not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_machine(
|
||||
machine_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific machine by ID.
|
||||
|
||||
- **machine_id**: UUID of the machine to retrieve
|
||||
|
||||
Returns the complete machine details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/machines/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Main Development Laptop",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"os_version": "Windows 11 Pro",
|
||||
"username": "technician",
|
||||
"home_directory": "C:\\\\Users\\\\technician",
|
||||
"has_vpn_access": true,
|
||||
"has_docker": true,
|
||||
"has_powershell": true,
|
||||
"powershell_version": "7.4.0",
|
||||
"is_primary": true,
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
machine = machine_service.get_machine_by_id(db, machine_id)
|
||||
return MachineResponse.model_validate(machine)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=MachineResponse,
|
||||
summary="Create new machine",
|
||||
description="Create a new machine with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Machine created successfully",
|
||||
"model": MachineResponse,
|
||||
},
|
||||
409: {
|
||||
"description": "Machine with hostname already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Machine with hostname 'laptop-dev-01' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "hostname"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_machine(
|
||||
machine_data: MachineCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new machine.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/machines
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Main Development Laptop",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"os_version": "Windows 11 Pro",
|
||||
"username": "technician",
|
||||
"home_directory": "C:\\\\Users\\\\technician",
|
||||
"has_vpn_access": true,
|
||||
"has_docker": true,
|
||||
"has_powershell": true,
|
||||
"powershell_version": "7.4.0",
|
||||
"has_ssh": true,
|
||||
"has_git": true,
|
||||
"claude_working_directory": "D:\\\\Projects",
|
||||
"preferred_shell": "powershell",
|
||||
"is_primary": true,
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Main Development Laptop",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"is_primary": true,
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
machine = machine_service.create_machine(db, machine_data)
|
||||
return MachineResponse.model_validate(machine)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{machine_id}",
|
||||
response_model=MachineResponse,
|
||||
summary="Update machine",
|
||||
description="Update an existing machine's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Machine updated successfully",
|
||||
"model": MachineResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Machine not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Conflict with existing machine",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Machine with hostname 'laptop-dev-01' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_machine(
|
||||
machine_id: UUID,
|
||||
machine_data: MachineUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing machine.
|
||||
|
||||
- **machine_id**: UUID of the machine to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/machines/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"friendly_name": "Updated Laptop Name",
|
||||
"is_active": false,
|
||||
"notes": "Machine being retired"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Updated Laptop Name",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"is_active": false,
|
||||
"notes": "Machine being retired",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
machine = machine_service.update_machine(db, machine_id, machine_data)
|
||||
return MachineResponse.model_validate(machine)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{machine_id}",
|
||||
response_model=dict,
|
||||
summary="Delete machine",
|
||||
description="Delete a machine by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Machine deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Machine deleted successfully",
|
||||
"machine_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Machine not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_machine(
|
||||
machine_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a machine.
|
||||
|
||||
- **machine_id**: UUID of the machine to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/machines/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Machine deleted successfully",
|
||||
"machine_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return machine_service.delete_machine(db, machine_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/primary/info",
|
||||
response_model=MachineResponse,
|
||||
summary="Get primary machine",
|
||||
description="Retrieve the machine marked as primary",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Primary machine found",
|
||||
"model": MachineResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "No primary machine configured",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "No primary machine is configured"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_primary_machine(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get the primary machine.
|
||||
|
||||
Returns the machine that is marked as the primary machine for MSP work.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/machines/primary/info
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"hostname": "laptop-dev-01",
|
||||
"friendly_name": "Main Development Laptop",
|
||||
"machine_type": "laptop",
|
||||
"platform": "win32",
|
||||
"is_primary": true,
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
primary_machine = machine_service.get_primary_machine(db)
|
||||
|
||||
if not primary_machine:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No primary machine is configured"
|
||||
)
|
||||
|
||||
return MachineResponse.model_validate(primary_machine)
|
||||
457
api/routers/networks.py
Normal file
457
api/routers/networks.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Network API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing networks, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.network import (
|
||||
NetworkCreate,
|
||||
NetworkResponse,
|
||||
NetworkUpdate,
|
||||
)
|
||||
from api.services import network_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all networks",
|
||||
description="Retrieve a paginated list of all networks with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_networks(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all networks with pagination.
|
||||
|
||||
- **skip**: Number of networks to skip (default: 0)
|
||||
- **limit**: Maximum number of networks to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of networks with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/networks?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"networks": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.1",
|
||||
"vlan_id": null,
|
||||
"notes": "Primary office network",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
networks, total = network_service.get_networks(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"networks": [NetworkResponse.model_validate(network) for network in networks]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve networks: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-site/{site_id}",
|
||||
response_model=dict,
|
||||
summary="Get networks by site",
|
||||
description="Retrieve all networks for a specific site with pagination",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Networks found and returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 3,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"networks": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.1",
|
||||
"vlan_id": None,
|
||||
"notes": "Primary office network",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Site not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Site with ID def12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_networks_by_site(
|
||||
site_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all networks for a specific site.
|
||||
|
||||
- **site_id**: UUID of the site
|
||||
- **skip**: Number of networks to skip (default: 0)
|
||||
- **limit**: Maximum number of networks to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of networks for the specified site with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/networks/by-site/def12345-6789-0def-1234-56789abcdef0?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
"""
|
||||
networks, total = network_service.get_networks_by_site(db, site_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"networks": [NetworkResponse.model_validate(network) for network in networks]
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{network_id}",
|
||||
response_model=NetworkResponse,
|
||||
summary="Get network by ID",
|
||||
description="Retrieve a single network by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Network found and returned",
|
||||
"model": NetworkResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Network not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Network with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_network(
|
||||
network_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific network by ID.
|
||||
|
||||
- **network_id**: UUID of the network to retrieve
|
||||
|
||||
Returns the complete network details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/networks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.1",
|
||||
"vlan_id": null,
|
||||
"notes": "Primary office network",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
network = network_service.get_network_by_id(db, network_id)
|
||||
return NetworkResponse.model_validate(network)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=NetworkResponse,
|
||||
summary="Create new network",
|
||||
description="Create a new network with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Network created successfully",
|
||||
"model": NetworkResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Site not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Site with ID def12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "network_name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_network(
|
||||
network_data: NetworkCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new network.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The site_id must reference an existing site if provided.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/networks
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.1",
|
||||
"vlan_id": null,
|
||||
"notes": "Primary office network"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.1",
|
||||
"vlan_id": null,
|
||||
"notes": "Primary office network",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
network = network_service.create_network(db, network_data)
|
||||
return NetworkResponse.model_validate(network)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{network_id}",
|
||||
response_model=NetworkResponse,
|
||||
summary="Update network",
|
||||
description="Update an existing network's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Network updated successfully",
|
||||
"model": NetworkResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Network or site not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Network with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_network(
|
||||
network_id: UUID,
|
||||
network_data: NetworkUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing network.
|
||||
|
||||
- **network_id**: UUID of the network to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating site_id, the new site must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/networks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"gateway_ip": "192.168.1.254",
|
||||
"notes": "Gateway IP updated for redundancy"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"site_id": "def12345-6789-0def-1234-56789abcdef0",
|
||||
"network_name": "Main LAN",
|
||||
"network_type": "lan",
|
||||
"cidr": "192.168.1.0/24",
|
||||
"gateway_ip": "192.168.1.254",
|
||||
"vlan_id": null,
|
||||
"notes": "Gateway IP updated for redundancy",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
network = network_service.update_network(db, network_id, network_data)
|
||||
return NetworkResponse.model_validate(network)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{network_id}",
|
||||
response_model=dict,
|
||||
summary="Delete network",
|
||||
description="Delete a network by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Network deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Network deleted successfully",
|
||||
"network_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Network not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Network with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_network(
|
||||
network_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a network.
|
||||
|
||||
- **network_id**: UUID of the network to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/networks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Network deleted successfully",
|
||||
"network_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return network_service.delete_network(db, network_id)
|
||||
202
api/routers/project_states.py
Normal file
202
api/routers/project_states.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
ProjectState API router for ClaudeTools.
|
||||
|
||||
Defines all REST API endpoints for managing project states,
|
||||
tracking the current state of projects for context retrieval.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.project_state import (
|
||||
ProjectStateCreate,
|
||||
ProjectStateResponse,
|
||||
ProjectStateUpdate,
|
||||
)
|
||||
from api.services import project_state_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all project states",
|
||||
description="Retrieve a paginated list of all project states",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_project_states(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all project states with pagination.
|
||||
|
||||
Returns project states ordered by most recently updated.
|
||||
"""
|
||||
try:
|
||||
states, total = project_state_service.get_project_states(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"states": [ProjectStateResponse.model_validate(state) for state in states]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve project states: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-project/{project_id}",
|
||||
response_model=ProjectStateResponse,
|
||||
summary="Get project state by project ID",
|
||||
description="Retrieve the project state for a specific project (unique per project)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_project_state_by_project(
|
||||
project_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get the project state for a specific project.
|
||||
|
||||
Each project has exactly one project state.
|
||||
"""
|
||||
state = project_state_service.get_project_state_by_project(db, project_id)
|
||||
|
||||
if not state:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"ProjectState for project ID {project_id} not found"
|
||||
)
|
||||
|
||||
return ProjectStateResponse.model_validate(state)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{state_id}",
|
||||
response_model=ProjectStateResponse,
|
||||
summary="Get project state by ID",
|
||||
description="Retrieve a single project state by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_project_state(
|
||||
state_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific project state by ID.
|
||||
"""
|
||||
state = project_state_service.get_project_state_by_id(db, state_id)
|
||||
return ProjectStateResponse.model_validate(state)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ProjectStateResponse,
|
||||
summary="Create new project state",
|
||||
description="Create a new project state with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_project_state(
|
||||
state_data: ProjectStateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new project state.
|
||||
|
||||
Each project can only have one project state (enforced by unique constraint).
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
"""
|
||||
state = project_state_service.create_project_state(db, state_data)
|
||||
return ProjectStateResponse.model_validate(state)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{state_id}",
|
||||
response_model=ProjectStateResponse,
|
||||
summary="Update project state",
|
||||
description="Update an existing project state's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_project_state(
|
||||
state_id: UUID,
|
||||
state_data: ProjectStateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing project state.
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
Uses compression utilities when updating to maintain efficient storage.
|
||||
"""
|
||||
state = project_state_service.update_project_state(db, state_id, state_data)
|
||||
return ProjectStateResponse.model_validate(state)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/by-project/{project_id}",
|
||||
response_model=ProjectStateResponse,
|
||||
summary="Update project state by project ID",
|
||||
description="Update project state by project ID (creates if doesn't exist)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_project_state_by_project(
|
||||
project_id: UUID,
|
||||
state_data: ProjectStateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update project state by project ID.
|
||||
|
||||
Convenience method that creates a new project state if it doesn't exist,
|
||||
or updates the existing one if it does.
|
||||
"""
|
||||
state = project_state_service.update_project_state_by_project(db, project_id, state_data)
|
||||
return ProjectStateResponse.model_validate(state)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{state_id}",
|
||||
response_model=dict,
|
||||
summary="Delete project state",
|
||||
description="Delete a project state by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_project_state(
|
||||
state_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a project state.
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
"""
|
||||
return project_state_service.delete_project_state(db, state_id)
|
||||
413
api/routers/projects.py
Normal file
413
api/routers/projects.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Project API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing projects, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectResponse,
|
||||
ProjectUpdate,
|
||||
)
|
||||
from api.services import project_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all projects",
|
||||
description="Retrieve a paginated list of all projects with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_projects(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
client_id: str = Query(
|
||||
default=None,
|
||||
description="Filter projects by client ID"
|
||||
),
|
||||
status_filter: str = Query(
|
||||
default=None,
|
||||
description="Filter projects by status (complete, working, blocked, pending, critical, deferred)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all projects with pagination and optional filtering.
|
||||
|
||||
- **skip**: Number of projects to skip (default: 0)
|
||||
- **limit**: Maximum number of projects to return (default: 100, max: 1000)
|
||||
- **client_id**: Filter by client ID (optional)
|
||||
- **status_filter**: Filter by status (optional)
|
||||
|
||||
Returns a list of projects with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/projects?skip=0&limit=50&status_filter=working
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"projects": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"name": "Website Redesign",
|
||||
"slug": "website-redesign",
|
||||
"category": "client_project",
|
||||
"status": "working",
|
||||
"priority": "high",
|
||||
"description": "Complete website overhaul",
|
||||
"started_date": "2024-01-15",
|
||||
"target_completion_date": "2024-03-15",
|
||||
"estimated_hours": 120.00,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
if client_id:
|
||||
projects, total = project_service.get_projects_by_client(db, client_id, skip, limit)
|
||||
elif status_filter:
|
||||
projects, total = project_service.get_projects_by_status(db, status_filter, skip, limit)
|
||||
else:
|
||||
projects, total = project_service.get_projects(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"projects": [ProjectResponse.model_validate(project) for project in projects]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve projects: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}",
|
||||
response_model=ProjectResponse,
|
||||
summary="Get project by ID",
|
||||
description="Retrieve a single project by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Project found and returned",
|
||||
"model": ProjectResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Project not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_project(
|
||||
project_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific project by ID.
|
||||
|
||||
- **project_id**: UUID of the project to retrieve
|
||||
|
||||
Returns the complete project details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/projects/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"name": "Website Redesign",
|
||||
"slug": "website-redesign",
|
||||
"category": "client_project",
|
||||
"status": "working",
|
||||
"priority": "high",
|
||||
"description": "Complete website overhaul with new branding",
|
||||
"started_date": "2024-01-15",
|
||||
"target_completion_date": "2024-03-15",
|
||||
"completed_date": null,
|
||||
"estimated_hours": 120.00,
|
||||
"actual_hours": 45.50,
|
||||
"gitea_repo_url": "https://gitea.example.com/client/website",
|
||||
"notes": "Client requested mobile-first approach",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-20T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
project = project_service.get_project_by_id(db, project_id)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ProjectResponse,
|
||||
summary="Create new project",
|
||||
description="Create a new project with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Project created successfully",
|
||||
"model": ProjectResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Project with slug already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with slug 'website-redesign' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_project(
|
||||
project_data: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new project.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The client_id must reference an existing client.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/projects
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"name": "Website Redesign",
|
||||
"slug": "website-redesign",
|
||||
"category": "client_project",
|
||||
"status": "working",
|
||||
"priority": "high",
|
||||
"description": "Complete website overhaul with new branding",
|
||||
"started_date": "2024-01-15",
|
||||
"target_completion_date": "2024-03-15",
|
||||
"estimated_hours": 120.00,
|
||||
"gitea_repo_url": "https://gitea.example.com/client/website",
|
||||
"notes": "Client requested mobile-first approach"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"name": "Website Redesign",
|
||||
"slug": "website-redesign",
|
||||
"status": "working",
|
||||
"priority": "high",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
project = project_service.create_project(db, project_data)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}",
|
||||
response_model=ProjectResponse,
|
||||
summary="Update project",
|
||||
description="Update an existing project's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Project updated successfully",
|
||||
"model": ProjectResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Project or client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Conflict with existing project",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with slug 'website-redesign' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_project(
|
||||
project_id: UUID,
|
||||
project_data: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing project.
|
||||
|
||||
- **project_id**: UUID of the project to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating client_id, the new client must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/projects/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "completed",
|
||||
"completed_date": "2024-03-10",
|
||||
"actual_hours": 118.50,
|
||||
"notes": "Project completed ahead of schedule"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"name": "Website Redesign",
|
||||
"slug": "website-redesign",
|
||||
"status": "completed",
|
||||
"completed_date": "2024-03-10",
|
||||
"actual_hours": 118.50,
|
||||
"notes": "Project completed ahead of schedule",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-03-10T16:45:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
project = project_service.update_project(db, project_id, project_data)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}",
|
||||
response_model=dict,
|
||||
summary="Delete project",
|
||||
description="Delete a project by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Project deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Project deleted successfully",
|
||||
"project_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Project not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_project(
|
||||
project_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a project.
|
||||
|
||||
- **project_id**: UUID of the project to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/projects/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Project deleted successfully",
|
||||
"project_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return project_service.delete_project(db, project_id)
|
||||
253
api/routers/security_incidents.py
Normal file
253
api/routers/security_incidents.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Security Incidents API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing security incidents.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.security_incident import (
|
||||
SecurityIncidentCreate,
|
||||
SecurityIncidentResponse,
|
||||
SecurityIncidentUpdate,
|
||||
)
|
||||
from api.services import security_incident_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all security incidents",
|
||||
description="Retrieve a paginated list of all security incidents",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_security_incidents(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all security incidents with pagination.
|
||||
|
||||
- **skip**: Number of incidents to skip (default: 0)
|
||||
- **limit**: Maximum number of incidents to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of security incidents with pagination metadata.
|
||||
Incidents are ordered by incident_date descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
incidents, total = security_incident_service.get_security_incidents(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"incidents": [SecurityIncidentResponse.model_validate(incident) for incident in incidents]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve security incidents: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{incident_id}",
|
||||
response_model=SecurityIncidentResponse,
|
||||
summary="Get security incident by ID",
|
||||
description="Retrieve a single security incident by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_security_incident(
|
||||
incident_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific security incident by ID.
|
||||
|
||||
- **incident_id**: UUID of the security incident to retrieve
|
||||
|
||||
Returns the complete security incident details including investigation
|
||||
findings, remediation steps, and current status.
|
||||
"""
|
||||
incident = security_incident_service.get_security_incident_by_id(db, incident_id)
|
||||
return SecurityIncidentResponse.model_validate(incident)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SecurityIncidentResponse,
|
||||
summary="Create new security incident",
|
||||
description="Create a new security incident record",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_security_incident(
|
||||
incident_data: SecurityIncidentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new security incident.
|
||||
|
||||
Records a new security incident including the incident type, severity,
|
||||
affected resources, and initial description. Status defaults to 'investigating'.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
"""
|
||||
incident = security_incident_service.create_security_incident(db, incident_data)
|
||||
return SecurityIncidentResponse.model_validate(incident)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{incident_id}",
|
||||
response_model=SecurityIncidentResponse,
|
||||
summary="Update security incident",
|
||||
description="Update an existing security incident's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def update_security_incident(
|
||||
incident_id: UUID,
|
||||
incident_data: SecurityIncidentUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing security incident.
|
||||
|
||||
- **incident_id**: UUID of the security incident to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
Commonly updated fields include status, findings, remediation_steps,
|
||||
and resolved_at timestamp.
|
||||
"""
|
||||
incident = security_incident_service.update_security_incident(db, incident_id, incident_data)
|
||||
return SecurityIncidentResponse.model_validate(incident)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{incident_id}",
|
||||
response_model=dict,
|
||||
summary="Delete security incident",
|
||||
description="Delete a security incident by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def delete_security_incident(
|
||||
incident_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a security incident.
|
||||
|
||||
- **incident_id**: UUID of the security incident to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
Consider setting status to 'resolved' instead of deleting for audit purposes.
|
||||
"""
|
||||
return security_incident_service.delete_security_incident(db, incident_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get security incidents by client",
|
||||
description="Retrieve all security incidents for a specific client",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_security_incidents_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all security incidents for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of incidents to skip (default: 0)
|
||||
- **limit**: Maximum number of incidents to return (default: 100, max: 1000)
|
||||
|
||||
Returns incidents ordered by incident_date descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
incidents, total = security_incident_service.get_security_incidents_by_client(
|
||||
db, client_id, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"client_id": str(client_id),
|
||||
"incidents": [SecurityIncidentResponse.model_validate(incident) for incident in incidents]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve security incidents for client: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-status/{status_filter}",
|
||||
response_model=dict,
|
||||
summary="Get security incidents by status",
|
||||
description="Retrieve all security incidents with a specific status",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_security_incidents_by_status(
|
||||
status_filter: str = Path(..., description="Status: investigating, contained, resolved, monitoring"),
|
||||
skip: int = Query(default=0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(default=100, ge=1, le=1000, description="Maximum number of records to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all security incidents with a specific status.
|
||||
|
||||
- **status_filter**: Status to filter by (investigating, contained, resolved, monitoring)
|
||||
- **skip**: Number of incidents to skip (default: 0)
|
||||
- **limit**: Maximum number of incidents to return (default: 100, max: 1000)
|
||||
|
||||
Returns incidents ordered by incident_date descending (most recent first).
|
||||
"""
|
||||
try:
|
||||
incidents, total = security_incident_service.get_security_incidents_by_status(
|
||||
db, status_filter, skip, limit
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"status": status_filter,
|
||||
"incidents": [SecurityIncidentResponse.model_validate(incident) for incident in incidents]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve security incidents by status: {str(e)}"
|
||||
)
|
||||
490
api/routers/services.py
Normal file
490
api/routers/services.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Service API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing services, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.service import (
|
||||
ServiceCreate,
|
||||
ServiceResponse,
|
||||
ServiceUpdate,
|
||||
)
|
||||
from api.services import service_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all services",
|
||||
description="Retrieve a paginated list of all services with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_services(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
client_id: str = Query(
|
||||
default=None,
|
||||
description="Filter services by client ID (via infrastructure)"
|
||||
),
|
||||
service_type: str = Query(
|
||||
default=None,
|
||||
description="Filter services by type (e.g., 'git_hosting', 'database', 'web_server')"
|
||||
),
|
||||
status_filter: str = Query(
|
||||
default=None,
|
||||
description="Filter services by status (running, stopped, error, maintenance)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all services with pagination and optional filtering.
|
||||
|
||||
- **skip**: Number of services to skip (default: 0)
|
||||
- **limit**: Maximum number of services to return (default: 100, max: 1000)
|
||||
- **client_id**: Filter by client ID (optional)
|
||||
- **service_type**: Filter by service type (optional)
|
||||
- **status_filter**: Filter by status (optional)
|
||||
|
||||
Returns a list of services with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/services?skip=0&limit=50&status_filter=running
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 25,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"services": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"external_url": "https://gitea.example.com",
|
||||
"port": 3000,
|
||||
"protocol": "https",
|
||||
"status": "running",
|
||||
"version": "1.21.0",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
if client_id:
|
||||
services, total = service_service.get_services_by_client(db, client_id, skip, limit)
|
||||
elif service_type:
|
||||
services, total = service_service.get_services_by_type(db, service_type, skip, limit)
|
||||
elif status_filter:
|
||||
services, total = service_service.get_services_by_status(db, status_filter, skip, limit)
|
||||
else:
|
||||
services, total = service_service.get_services(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"services": [ServiceResponse.model_validate(service) for service in services]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve services: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_id}",
|
||||
response_model=ServiceResponse,
|
||||
summary="Get service by ID",
|
||||
description="Retrieve a single service by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Service found and returned",
|
||||
"model": ServiceResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Service not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Service with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_service(
|
||||
service_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific service by ID.
|
||||
|
||||
- **service_id**: UUID of the service to retrieve
|
||||
|
||||
Returns the complete service details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/services/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"external_url": "https://gitea.example.com",
|
||||
"internal_url": "http://192.168.1.10:3000",
|
||||
"port": 3000,
|
||||
"protocol": "https",
|
||||
"status": "running",
|
||||
"version": "1.21.0",
|
||||
"notes": "Primary Git server for code repositories",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-20T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
service = service_service.get_service_by_id(db, service_id)
|
||||
return ServiceResponse.model_validate(service)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ServiceResponse,
|
||||
summary="Create new service",
|
||||
description="Create a new service with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Service created successfully",
|
||||
"model": ServiceResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Infrastructure not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Infrastructure with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "service_name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_service(
|
||||
service_data: ServiceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new service.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The infrastructure_id must reference an existing infrastructure if provided.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/services
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"external_url": "https://gitea.example.com",
|
||||
"internal_url": "http://192.168.1.10:3000",
|
||||
"port": 3000,
|
||||
"protocol": "https",
|
||||
"status": "running",
|
||||
"version": "1.21.0",
|
||||
"notes": "Primary Git server for code repositories"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
service = service_service.create_service(db, service_data)
|
||||
return ServiceResponse.model_validate(service)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{service_id}",
|
||||
response_model=ServiceResponse,
|
||||
summary="Update service",
|
||||
description="Update an existing service's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Service updated successfully",
|
||||
"model": ServiceResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Service or infrastructure not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Service with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_service(
|
||||
service_id: UUID,
|
||||
service_data: ServiceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing service.
|
||||
|
||||
- **service_id**: UUID of the service to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating infrastructure_id, the new infrastructure must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/services/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "maintenance",
|
||||
"version": "1.22.0",
|
||||
"notes": "Upgraded to latest version, temporarily in maintenance mode"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"status": "maintenance",
|
||||
"version": "1.22.0",
|
||||
"notes": "Upgraded to latest version, temporarily in maintenance mode",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-03-10T16:45:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
service = service_service.update_service(db, service_id, service_data)
|
||||
return ServiceResponse.model_validate(service)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{service_id}",
|
||||
response_model=dict,
|
||||
summary="Delete service",
|
||||
description="Delete a service by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Service deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Service deleted successfully",
|
||||
"service_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Service not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Service with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_service(
|
||||
service_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a service.
|
||||
|
||||
- **service_id**: UUID of the service to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/services/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Service deleted successfully",
|
||||
"service_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return service_service.delete_service(db, service_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get services by client",
|
||||
description="Retrieve all services for a specific client (via infrastructure)",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Services found and returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"services": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_services_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all services for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of services to skip (default: 0)
|
||||
- **limit**: Maximum number of services to return (default: 100, max: 1000)
|
||||
|
||||
This endpoint retrieves services associated with a client's infrastructure.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/services/by-client/123e4567-e89b-12d3-a456-426614174001?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"services": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"infrastructure_id": "123e4567-e89b-12d3-a456-426614174002",
|
||||
"service_name": "Gitea",
|
||||
"service_type": "git_hosting",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
services, total = service_service.get_services_by_client(db, str(client_id), skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"services": [ServiceResponse.model_validate(service) for service in services]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve services for client: {str(e)}"
|
||||
)
|
||||
400
api/routers/sessions.py
Normal file
400
api/routers/sessions.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Session API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing sessions, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.session import (
|
||||
SessionCreate,
|
||||
SessionResponse,
|
||||
SessionUpdate,
|
||||
)
|
||||
from api.services import session_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all sessions",
|
||||
description="Retrieve a paginated list of all sessions with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_sessions(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
project_id: UUID | None = Query(
|
||||
default=None,
|
||||
description="Filter sessions by project ID"
|
||||
),
|
||||
machine_id: UUID | None = Query(
|
||||
default=None,
|
||||
description="Filter sessions by machine ID"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all sessions with pagination.
|
||||
|
||||
- **skip**: Number of sessions to skip (default: 0)
|
||||
- **limit**: Maximum number of sessions to return (default: 100, max: 1000)
|
||||
- **project_id**: Optional filter by project ID
|
||||
- **machine_id**: Optional filter by machine ID
|
||||
|
||||
Returns a list of sessions with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/sessions?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_title": "Database migration work",
|
||||
"session_date": "2024-01-15",
|
||||
"status": "completed",
|
||||
"duration_minutes": 120,
|
||||
"is_billable": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
sessions, total = session_service.get_sessions_by_project(db, project_id, skip, limit)
|
||||
# Filter by machine if specified
|
||||
elif machine_id:
|
||||
sessions, total = session_service.get_sessions_by_machine(db, machine_id, skip, limit)
|
||||
# Otherwise get all sessions
|
||||
else:
|
||||
sessions, total = session_service.get_sessions(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"sessions": [SessionResponse.model_validate(session) for session in sessions]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve sessions: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}",
|
||||
response_model=SessionResponse,
|
||||
summary="Get session by ID",
|
||||
description="Retrieve a single session by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Session found and returned",
|
||||
"model": SessionResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Session with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_session(
|
||||
session_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific session by ID.
|
||||
|
||||
- **session_id**: UUID of the session to retrieve
|
||||
|
||||
Returns the complete session details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/sessions/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"project_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"machine_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"session_date": "2024-01-15",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"end_time": "2024-01-15T11:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"status": "completed",
|
||||
"session_title": "Database migration work",
|
||||
"summary": "Migrated customer database to new schema version",
|
||||
"is_billable": true,
|
||||
"billable_hours": 2.0,
|
||||
"technician": "John Doe",
|
||||
"session_log_file": "/logs/2024-01-15-db-migration.md",
|
||||
"notes": "Successful migration with no issues",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
session = session_service.get_session_by_id(db, session_id)
|
||||
return SessionResponse.model_validate(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SessionResponse,
|
||||
summary="Create new session",
|
||||
description="Create a new session with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Session created successfully",
|
||||
"model": SessionResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Referenced project or machine not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Project with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "session_title"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_session(
|
||||
session_data: SessionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new session.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/sessions
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_title": "Database migration work",
|
||||
"session_date": "2024-01-15",
|
||||
"project_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"machine_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"start_time": "2024-01-15T09:00:00Z",
|
||||
"end_time": "2024-01-15T11:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"status": "completed",
|
||||
"summary": "Migrated customer database to new schema version",
|
||||
"is_billable": true,
|
||||
"billable_hours": 2.0,
|
||||
"technician": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_title": "Database migration work",
|
||||
"session_date": "2024-01-15",
|
||||
"status": "completed",
|
||||
"duration_minutes": 120,
|
||||
"is_billable": true,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
session = session_service.create_session(db, session_data)
|
||||
return SessionResponse.model_validate(session)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{session_id}",
|
||||
response_model=SessionResponse,
|
||||
summary="Update session",
|
||||
description="Update an existing session's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Session updated successfully",
|
||||
"model": SessionResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Session, project, or machine not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Session with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Invalid project_id"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_session(
|
||||
session_id: UUID,
|
||||
session_data: SessionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing session.
|
||||
|
||||
- **session_id**: UUID of the session to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/sessions/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "completed",
|
||||
"end_time": "2024-01-15T11:00:00Z",
|
||||
"duration_minutes": 120,
|
||||
"summary": "Successfully completed database migration"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_title": "Database migration work",
|
||||
"session_date": "2024-01-15",
|
||||
"status": "completed",
|
||||
"duration_minutes": 120,
|
||||
"summary": "Successfully completed database migration",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
session = session_service.update_session(db, session_id, session_data)
|
||||
return SessionResponse.model_validate(session)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{session_id}",
|
||||
response_model=dict,
|
||||
summary="Delete session",
|
||||
description="Delete a session by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Session deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Session deleted successfully",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Session with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_session(
|
||||
session_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
- **session_id**: UUID of the session to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/sessions/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Session deleted successfully",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return session_service.delete_session(db, session_id)
|
||||
457
api/routers/sites.py
Normal file
457
api/routers/sites.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Site API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing sites, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.site import (
|
||||
SiteCreate,
|
||||
SiteResponse,
|
||||
SiteUpdate,
|
||||
)
|
||||
from api.services import site_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all sites",
|
||||
description="Retrieve a paginated list of all sites with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_sites(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all sites with pagination.
|
||||
|
||||
- **skip**: Number of sites to skip (default: 0)
|
||||
- **limit**: Maximum number of sites to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of sites with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/sites?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 5,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"sites": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": true,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "Primary office location",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
sites, total = site_service.get_sites(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"sites": [SiteResponse.model_validate(site) for site in sites]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve sites: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get sites by client",
|
||||
description="Retrieve all sites for a specific client with pagination",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Sites found and returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"total": 3,
|
||||
"skip": 0,
|
||||
"limit": 100,
|
||||
"sites": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": True,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "Primary office location",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID abc12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_sites_by_client(
|
||||
client_id: UUID,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all sites for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of sites to skip (default: 0)
|
||||
- **limit**: Maximum number of sites to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of sites for the specified client with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/sites/by-client/abc12345-6789-0def-1234-56789abcdef0?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
"""
|
||||
sites, total = site_service.get_sites_by_client(db, client_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"sites": [SiteResponse.model_validate(site) for site in sites]
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}",
|
||||
response_model=SiteResponse,
|
||||
summary="Get site by ID",
|
||||
description="Retrieve a single site by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Site found and returned",
|
||||
"model": SiteResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Site not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Site with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_site(
|
||||
site_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific site by ID.
|
||||
|
||||
- **site_id**: UUID of the site to retrieve
|
||||
|
||||
Returns the complete site details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/sites/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": true,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "Primary office location",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
site = site_service.get_site_by_id(db, site_id)
|
||||
return SiteResponse.model_validate(site)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=SiteResponse,
|
||||
summary="Create new site",
|
||||
description="Create a new site with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Site created successfully",
|
||||
"model": SiteResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Client with ID abc12345-6789-0def-1234-56789abcdef0 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_site(
|
||||
site_data: SiteCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new site.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The client_id must reference an existing client.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/sites
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": true,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "Primary office location"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": true,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "Primary office location",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
site = site_service.create_site(db, site_data)
|
||||
return SiteResponse.model_validate(site)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{site_id}",
|
||||
response_model=SiteResponse,
|
||||
summary="Update site",
|
||||
description="Update an existing site's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Site updated successfully",
|
||||
"model": SiteResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Site or client not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Site with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_site(
|
||||
site_id: UUID,
|
||||
site_data: SiteUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing site.
|
||||
|
||||
- **site_id**: UUID of the site to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating client_id, the new client must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/sites/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"vpn_required": false,
|
||||
"notes": "VPN decommissioned"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"client_id": "abc12345-6789-0def-1234-56789abcdef0",
|
||||
"name": "Main Office",
|
||||
"network_subnet": "172.16.9.0/24",
|
||||
"vpn_required": false,
|
||||
"vpn_subnet": "192.168.1.0/24",
|
||||
"gateway_ip": "172.16.9.1",
|
||||
"dns_servers": "[\"8.8.8.8\", \"8.8.4.4\"]",
|
||||
"notes": "VPN decommissioned",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
site = site_service.update_site(db, site_id, site_data)
|
||||
return SiteResponse.model_validate(site)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{site_id}",
|
||||
response_model=dict,
|
||||
summary="Delete site",
|
||||
description="Delete a site by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Site deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Site deleted successfully",
|
||||
"site_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Site not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Site with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_site(
|
||||
site_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a site.
|
||||
|
||||
- **site_id**: UUID of the site to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/sites/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Site deleted successfully",
|
||||
"site_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return site_service.delete_site(db, site_id)
|
||||
365
api/routers/tags.py
Normal file
365
api/routers/tags.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Tag API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing tags, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.tag import (
|
||||
TagCreate,
|
||||
TagResponse,
|
||||
TagUpdate,
|
||||
)
|
||||
from api.services import tag_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all tags",
|
||||
description="Retrieve a paginated list of all tags with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_tags(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
category: str = Query(
|
||||
default=None,
|
||||
description="Filter by category (technology, client, infrastructure, problem_type, action, service)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all tags with pagination.
|
||||
|
||||
- **skip**: Number of tags to skip (default: 0)
|
||||
- **limit**: Maximum number of tags to return (default: 100, max: 1000)
|
||||
- **category**: Filter by category (optional)
|
||||
|
||||
Returns a list of tags with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/tags?skip=0&limit=50&category=technology
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"tags": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Windows",
|
||||
"category": "technology",
|
||||
"description": "Microsoft Windows operating system",
|
||||
"usage_count": 42,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
if category:
|
||||
tags, total = tag_service.get_tags_by_category(db, category, skip, limit)
|
||||
else:
|
||||
tags, total = tag_service.get_tags(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"tags": [TagResponse.model_validate(tag) for tag in tags]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve tags: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tag_id}",
|
||||
response_model=TagResponse,
|
||||
summary="Get tag by ID",
|
||||
description="Retrieve a single tag by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Tag found and returned",
|
||||
"model": TagResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Tag not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Tag with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_tag(
|
||||
tag_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific tag by ID.
|
||||
|
||||
- **tag_id**: UUID of the tag to retrieve
|
||||
|
||||
Returns the complete tag details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/tags/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Windows",
|
||||
"category": "technology",
|
||||
"description": "Microsoft Windows operating system",
|
||||
"usage_count": 42,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tag = tag_service.get_tag_by_id(db, tag_id)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TagResponse,
|
||||
summary="Create new tag",
|
||||
description="Create a new tag with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Tag created successfully",
|
||||
"model": TagResponse,
|
||||
},
|
||||
409: {
|
||||
"description": "Tag with name already exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Tag with name 'Windows' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_tag(
|
||||
tag_data: TagCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new tag.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/tags
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Windows",
|
||||
"category": "technology",
|
||||
"description": "Microsoft Windows operating system"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Windows",
|
||||
"category": "technology",
|
||||
"description": "Microsoft Windows operating system",
|
||||
"usage_count": 0,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tag = tag_service.create_tag(db, tag_data)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{tag_id}",
|
||||
response_model=TagResponse,
|
||||
summary="Update tag",
|
||||
description="Update an existing tag's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Tag updated successfully",
|
||||
"model": TagResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Tag not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Tag with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
409: {
|
||||
"description": "Conflict with existing tag",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Tag with name 'Windows' already exists"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_tag(
|
||||
tag_id: UUID,
|
||||
tag_data: TagUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing tag.
|
||||
|
||||
- **tag_id**: UUID of the tag to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/tags/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"description": "Updated description for Windows",
|
||||
"category": "infrastructure"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"name": "Windows",
|
||||
"category": "infrastructure",
|
||||
"description": "Updated description for Windows",
|
||||
"usage_count": 42,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T14:20:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
tag = tag_service.update_tag(db, tag_id, tag_data)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tag_id}",
|
||||
response_model=dict,
|
||||
summary="Delete tag",
|
||||
description="Delete a tag by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Tag deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Tag deleted successfully",
|
||||
"tag_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Tag not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Tag with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_tag(
|
||||
tag_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a tag.
|
||||
|
||||
- **tag_id**: UUID of the tag to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/tags/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Tag deleted successfully",
|
||||
"tag_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return tag_service.delete_tag(db, tag_id)
|
||||
395
api/routers/tasks.py
Normal file
395
api/routers/tasks.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Task API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing tasks, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from api.services import task_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all tasks",
|
||||
description="Retrieve a paginated list of all tasks with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_tasks(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
session_id: UUID | None = Query(
|
||||
default=None,
|
||||
description="Filter tasks by session ID"
|
||||
),
|
||||
status_filter: str | None = Query(
|
||||
default=None,
|
||||
description="Filter tasks by status (pending, in_progress, blocked, completed, cancelled)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all tasks with pagination.
|
||||
|
||||
- **skip**: Number of tasks to skip (default: 0)
|
||||
- **limit**: Maximum number of tasks to return (default: 100, max: 1000)
|
||||
- **session_id**: Optional filter by session ID
|
||||
- **status_filter**: Optional filter by status
|
||||
|
||||
Returns a list of tasks with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/tasks?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 25,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"tasks": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"title": "Implement authentication",
|
||||
"task_order": 1,
|
||||
"status": "in_progress",
|
||||
"task_type": "implementation",
|
||||
"estimated_complexity": "moderate",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Filter by session if specified
|
||||
if session_id:
|
||||
tasks, total = task_service.get_tasks_by_session(db, session_id, skip, limit)
|
||||
# Filter by status if specified
|
||||
elif status_filter:
|
||||
tasks, total = task_service.get_tasks_by_status(db, status_filter, skip, limit)
|
||||
# Otherwise get all tasks
|
||||
else:
|
||||
tasks, total = task_service.get_tasks(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"tasks": [TaskResponse.model_validate(task) for task in tasks]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve tasks: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
response_model=TaskResponse,
|
||||
summary="Get task by ID",
|
||||
description="Retrieve a single task by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Task found and returned",
|
||||
"model": TaskResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Task not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Task with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_task(
|
||||
task_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific task by ID.
|
||||
|
||||
- **task_id**: UUID of the task to retrieve
|
||||
|
||||
Returns the complete task details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/tasks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"parent_task_id": null,
|
||||
"task_order": 1,
|
||||
"title": "Implement authentication",
|
||||
"description": "Add JWT-based authentication to the API",
|
||||
"task_type": "implementation",
|
||||
"status": "in_progress",
|
||||
"blocking_reason": null,
|
||||
"session_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"client_id": "789e0123-e89b-12d3-a456-426614174002",
|
||||
"project_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"assigned_agent": "agent-1",
|
||||
"estimated_complexity": "moderate",
|
||||
"started_at": "2024-01-15T09:00:00Z",
|
||||
"completed_at": null,
|
||||
"task_context": null,
|
||||
"dependencies": null,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
task = task_service.get_task_by_id(db, task_id)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TaskResponse,
|
||||
summary="Create new task",
|
||||
description="Create a new task with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Task created successfully",
|
||||
"model": TaskResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Referenced session, client, project, or parent task not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Session with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "title"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_task(
|
||||
task_data: TaskCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new task.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/tasks
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Implement authentication",
|
||||
"task_order": 1,
|
||||
"description": "Add JWT-based authentication to the API",
|
||||
"task_type": "implementation",
|
||||
"status": "pending",
|
||||
"session_id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"project_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"assigned_agent": "agent-1",
|
||||
"estimated_complexity": "moderate"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"title": "Implement authentication",
|
||||
"task_order": 1,
|
||||
"status": "pending",
|
||||
"task_type": "implementation",
|
||||
"estimated_complexity": "moderate",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
task = task_service.create_task(db, task_data)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{task_id}",
|
||||
response_model=TaskResponse,
|
||||
summary="Update task",
|
||||
description="Update an existing task's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Task updated successfully",
|
||||
"model": TaskResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Task, session, client, project, or parent task not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Task with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Invalid session_id"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_task(
|
||||
task_id: UUID,
|
||||
task_data: TaskUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing task.
|
||||
|
||||
- **task_id**: UUID of the task to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/tasks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "completed",
|
||||
"completed_at": "2024-01-15T15:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"title": "Implement authentication",
|
||||
"task_order": 1,
|
||||
"status": "completed",
|
||||
"completed_at": "2024-01-15T15:00:00Z",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T15:00:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
task = task_service.update_task(db, task_id, task_data)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{task_id}",
|
||||
response_model=dict,
|
||||
summary="Delete task",
|
||||
description="Delete a task by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Task deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Task deleted successfully",
|
||||
"task_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Task not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Task with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_task(
|
||||
task_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a task.
|
||||
|
||||
- **task_id**: UUID of the task to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/tasks/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Task deleted successfully",
|
||||
"task_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return task_service.delete_task(db, task_id)
|
||||
555
api/routers/work_items.py
Normal file
555
api/routers/work_items.py
Normal file
@@ -0,0 +1,555 @@
|
||||
"""
|
||||
Work Item API router for ClaudeTools.
|
||||
|
||||
This module defines all REST API endpoints for managing work items, including
|
||||
CRUD operations with proper authentication, validation, and error handling.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.database import get_db
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.schemas.work_item import (
|
||||
WorkItemCreate,
|
||||
WorkItemResponse,
|
||||
WorkItemUpdate,
|
||||
)
|
||||
from api.services import work_item_service
|
||||
|
||||
# Create router with prefix and tags
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=dict,
|
||||
summary="List all work items",
|
||||
description="Retrieve a paginated list of all work items with optional filtering",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def list_work_items(
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
session_id: str = Query(
|
||||
default=None,
|
||||
description="Filter work items by session ID"
|
||||
),
|
||||
status_filter: str = Query(
|
||||
default=None,
|
||||
description="Filter work items by status (completed, in_progress, blocked, pending, deferred)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all work items with pagination and optional filtering.
|
||||
|
||||
- **skip**: Number of work items to skip (default: 0)
|
||||
- **limit**: Maximum number of work items to return (default: 100, max: 1000)
|
||||
- **session_id**: Filter by session ID (optional)
|
||||
- **status_filter**: Filter by status (optional)
|
||||
|
||||
Returns a list of work items with pagination metadata.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/work-items?skip=0&limit=50&status_filter=in_progress
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 25,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"work_items": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"description": "Updated firewall rules for new server",
|
||||
"status": "completed",
|
||||
"priority": "high",
|
||||
"is_billable": true,
|
||||
"estimated_minutes": 30,
|
||||
"actual_minutes": 25,
|
||||
"affected_systems": "[\"jupiter\", \"172.16.3.20\"]",
|
||||
"technologies_used": "[\"iptables\", \"ufw\"]",
|
||||
"item_order": 1,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"completed_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
if session_id:
|
||||
work_items, total = work_item_service.get_work_items_by_session(db, session_id, skip, limit)
|
||||
elif status_filter:
|
||||
work_items, total = work_item_service.get_work_items_by_status(db, status_filter, skip, limit)
|
||||
else:
|
||||
work_items, total = work_item_service.get_work_items(db, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"work_items": [WorkItemResponse.model_validate(work_item) for work_item in work_items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve work items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{work_item_id}",
|
||||
response_model=WorkItemResponse,
|
||||
summary="Get work item by ID",
|
||||
description="Retrieve a single work item by its unique identifier",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Work item found and returned",
|
||||
"model": WorkItemResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Work item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Work item with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_work_item(
|
||||
work_item_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a specific work item by ID.
|
||||
|
||||
- **work_item_id**: UUID of the work item to retrieve
|
||||
|
||||
Returns the complete work item details.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/work-items/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"description": "Updated firewall rules for new server to allow web traffic",
|
||||
"status": "completed",
|
||||
"priority": "high",
|
||||
"is_billable": true,
|
||||
"estimated_minutes": 30,
|
||||
"actual_minutes": 25,
|
||||
"affected_systems": "[\"jupiter\", \"172.16.3.20\"]",
|
||||
"technologies_used": "[\"iptables\", \"ufw\"]",
|
||||
"item_order": 1,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"completed_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
work_item = work_item_service.get_work_item_by_id(db, work_item_id)
|
||||
return WorkItemResponse.model_validate(work_item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=WorkItemResponse,
|
||||
summary="Create new work item",
|
||||
description="Create a new work item with the provided details",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
201: {
|
||||
"description": "Work item created successfully",
|
||||
"model": WorkItemResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Session with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "title"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def create_work_item(
|
||||
work_item_data: WorkItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new work item.
|
||||
|
||||
Requires a valid JWT token with appropriate permissions.
|
||||
The session_id must reference an existing session.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /api/work-items
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"description": "Updated firewall rules for new server to allow web traffic",
|
||||
"status": "completed",
|
||||
"priority": "high",
|
||||
"is_billable": true,
|
||||
"estimated_minutes": 30,
|
||||
"actual_minutes": 25,
|
||||
"affected_systems": "[\"jupiter\", \"172.16.3.20\"]",
|
||||
"technologies_used": "[\"iptables\", \"ufw\"]",
|
||||
"item_order": 1,
|
||||
"completed_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"status": "completed",
|
||||
"priority": "high",
|
||||
"is_billable": true,
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
work_item = work_item_service.create_work_item(db, work_item_data)
|
||||
return WorkItemResponse.model_validate(work_item)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{work_item_id}",
|
||||
response_model=WorkItemResponse,
|
||||
summary="Update work item",
|
||||
description="Update an existing work item's details",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Work item updated successfully",
|
||||
"model": WorkItemResponse,
|
||||
},
|
||||
404: {
|
||||
"description": "Work item or session not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Work item with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
422: {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Invalid status. Must be one of: completed, in_progress, blocked, pending, deferred"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def update_work_item(
|
||||
work_item_id: UUID,
|
||||
work_item_data: WorkItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing work item.
|
||||
|
||||
- **work_item_id**: UUID of the work item to update
|
||||
|
||||
Only provided fields will be updated. All fields are optional.
|
||||
If updating session_id, the new session must exist.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
PUT /api/work-items/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "completed",
|
||||
"actual_minutes": 30,
|
||||
"completed_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"status": "completed",
|
||||
"actual_minutes": 30,
|
||||
"completed_at": "2024-01-15T11:00:00Z",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
work_item = work_item_service.update_work_item(db, work_item_id, work_item_data)
|
||||
return WorkItemResponse.model_validate(work_item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{work_item_id}",
|
||||
response_model=dict,
|
||||
summary="Delete work item",
|
||||
description="Delete a work item by its ID",
|
||||
status_code=status.HTTP_200_OK,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Work item deleted successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"message": "Work item deleted successfully",
|
||||
"work_item_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "Work item not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"detail": "Work item with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def delete_work_item(
|
||||
work_item_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a work item.
|
||||
|
||||
- **work_item_id**: UUID of the work item to delete
|
||||
|
||||
This is a permanent operation and cannot be undone.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
DELETE /api/work-items/123e4567-e89b-12d3-a456-426614174000
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Work item deleted successfully",
|
||||
"work_item_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
"""
|
||||
return work_item_service.delete_work_item(db, work_item_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-project/{project_id}",
|
||||
response_model=dict,
|
||||
summary="Get work items by project",
|
||||
description="Retrieve all work items associated with a specific project through sessions",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_work_items_by_project(
|
||||
project_id: str,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all work items for a specific project.
|
||||
|
||||
- **project_id**: UUID of the project
|
||||
- **skip**: Number of work items to skip (default: 0)
|
||||
- **limit**: Maximum number of work items to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of work items associated with the project through sessions.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/work-items/by-project/123e4567-e89b-12d3-a456-426614174000?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"project_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"work_items": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174002",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"status": "completed",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
work_items, total = work_item_service.get_work_items_by_project(db, project_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"project_id": project_id,
|
||||
"work_items": [WorkItemResponse.model_validate(work_item) for work_item in work_items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve work items for project: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/by-client/{client_id}",
|
||||
response_model=dict,
|
||||
summary="Get work items by client",
|
||||
description="Retrieve all work items associated with a specific client through sessions",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def get_work_items_by_client(
|
||||
client_id: str,
|
||||
skip: int = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of records to skip for pagination"
|
||||
),
|
||||
limit: int = Query(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=1000,
|
||||
description="Maximum number of records to return (max 1000)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get all work items for a specific client.
|
||||
|
||||
- **client_id**: UUID of the client
|
||||
- **skip**: Number of work items to skip (default: 0)
|
||||
- **limit**: Maximum number of work items to return (default: 100, max: 1000)
|
||||
|
||||
Returns a list of work items associated with the client through sessions.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/work-items/by-client/123e4567-e89b-12d3-a456-426614174000?skip=0&limit=50
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"total": 42,
|
||||
"skip": 0,
|
||||
"limit": 50,
|
||||
"client_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"work_items": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174001",
|
||||
"session_id": "123e4567-e89b-12d3-a456-426614174002",
|
||||
"category": "infrastructure",
|
||||
"title": "Configure firewall rules",
|
||||
"status": "completed",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
work_items, total = work_item_service.get_work_items_by_client(db, client_id, skip, limit)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"client_id": client_id,
|
||||
"work_items": [WorkItemResponse.model_validate(work_item) for work_item in work_items]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve work items for client: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user