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:
2026-01-17 06:00:26 -07:00
parent 1452361c21
commit 390b10b32c
201 changed files with 55619 additions and 34 deletions

1
api/routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API routers for ClaudeTools"""

View 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
View 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
View 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)

View 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)

View 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)

View 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
View 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)}"
)

View 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)

View 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)

View 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
View 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
View 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
View 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)

View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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)}"
)