feat(city-service): add Governance, Audit, Incidents API endpoints
- Added repo_governance.py with database operations - Added routes_governance.py (/api/v1/governance/*) - Added routes_audit.py (/api/v1/audit/*) - Added routes_incidents.py (/api/v1/incidents/*) - Updated main.py to include new routers
This commit is contained in:
@@ -16,6 +16,11 @@ import routes_city
|
||||
import ws_city
|
||||
import repo_city
|
||||
import migrations # Import migrations
|
||||
|
||||
# Governance API modules
|
||||
import routes_governance
|
||||
import routes_audit
|
||||
import routes_incidents
|
||||
from common.redis_client import get_redis, close_redis
|
||||
from presence_gateway import (
|
||||
websocket_global_presence,
|
||||
@@ -58,6 +63,11 @@ app.include_router(routes_city.router)
|
||||
app.include_router(routes_city.public_router)
|
||||
app.include_router(routes_city.api_router)
|
||||
|
||||
# Governance API routers
|
||||
app.include_router(routes_governance.router)
|
||||
app.include_router(routes_audit.router)
|
||||
app.include_router(routes_incidents.router)
|
||||
|
||||
# ============================================================================
|
||||
# Models
|
||||
# ============================================================================
|
||||
|
||||
901
services/city-service/repo_governance.py
Normal file
901
services/city-service/repo_governance.py
Normal file
@@ -0,0 +1,901 @@
|
||||
"""
|
||||
Repository для Governance, Audit, Incidents (PostgreSQL)
|
||||
Використовує таблиці: agents, event_outbox, incidents, incident_history, permissions, agent_revocations
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
|
||||
from repo_city import get_pool, generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Governance Repository
|
||||
# =============================================================================
|
||||
|
||||
async def get_city_governance_agents() -> List[dict]:
|
||||
"""Отримати City Governance агентів (DAARWIZZ, DARIO, DARIA)"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.avatar_url,
|
||||
a.status,
|
||||
a.gov_level,
|
||||
a.node_id,
|
||||
a.created_at
|
||||
FROM agents a
|
||||
WHERE a.gov_level = 'city_governance'
|
||||
AND COALESCE(a.is_archived, false) = false
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY a.display_name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_district_agents(district_id: str) -> List[dict]:
|
||||
"""Отримати агентів дистрикту (District Lead + core-team)"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.avatar_url,
|
||||
a.status,
|
||||
a.gov_level,
|
||||
aa.role,
|
||||
aa.scope_type,
|
||||
a.created_at
|
||||
FROM agents a
|
||||
LEFT JOIN agent_assignments aa ON a.id = aa.agent_id
|
||||
AND aa.scope_type = 'district'
|
||||
AND aa.scope_id = $1
|
||||
AND aa.status = 'active'
|
||||
WHERE (
|
||||
a.gov_level = 'district_lead'
|
||||
OR aa.id IS NOT NULL
|
||||
)
|
||||
AND COALESCE(a.is_archived, false) = false
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY
|
||||
CASE a.gov_level
|
||||
WHEN 'district_lead' THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
a.display_name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, district_id)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_microdao_agents(microdao_id: str) -> List[dict]:
|
||||
"""Отримати агентів MicroDAO (Orchestrator + workers)"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.avatar_url,
|
||||
a.status,
|
||||
a.gov_level,
|
||||
ma.role,
|
||||
ma.is_core,
|
||||
a.created_at
|
||||
FROM agents a
|
||||
JOIN microdao_agents ma ON a.id = ma.agent_id AND ma.microdao_id = $1
|
||||
WHERE COALESCE(a.is_archived, false) = false
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY
|
||||
ma.is_core DESC,
|
||||
CASE a.gov_level
|
||||
WHEN 'orchestrator' THEN 0
|
||||
WHEN 'core_team' THEN 1
|
||||
WHEN 'worker' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
a.display_name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, microdao_id)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_agents_by_level(level: str) -> List[dict]:
|
||||
"""Отримати агентів за gov_level"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
a.id,
|
||||
a.display_name,
|
||||
a.kind,
|
||||
a.avatar_url,
|
||||
a.status,
|
||||
a.gov_level,
|
||||
a.node_id,
|
||||
a.created_at
|
||||
FROM agents a
|
||||
WHERE a.gov_level = $1
|
||||
AND COALESCE(a.is_archived, false) = false
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY a.display_name
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, level)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_agent_roles(agent_id: str) -> dict:
|
||||
"""Отримати ролі та повноваження агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Get agent info
|
||||
agent_query = """
|
||||
SELECT
|
||||
id, display_name, gov_level, status,
|
||||
home_microdao_id, dais_identity_id
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
"""
|
||||
agent = await pool.fetchrow(agent_query, agent_id)
|
||||
if not agent:
|
||||
return {"error": "Agent not found"}
|
||||
|
||||
# Get assignments
|
||||
assignments_query = """
|
||||
SELECT
|
||||
id, scope_type, scope_id, role, status, created_at
|
||||
FROM agent_assignments
|
||||
WHERE agent_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
assignments = await pool.fetch(assignments_query, agent_id)
|
||||
|
||||
# Get permissions
|
||||
permissions_query = """
|
||||
SELECT
|
||||
action, target, scope_type, scope_id, granted_by, granted_at
|
||||
FROM permissions
|
||||
WHERE agent_id = $1
|
||||
ORDER BY granted_at DESC
|
||||
"""
|
||||
permissions = await pool.fetch(permissions_query, agent_id)
|
||||
|
||||
return {
|
||||
"agent": dict(agent),
|
||||
"assignments": [dict(a) for a in assignments],
|
||||
"permissions": [dict(p) for p in permissions]
|
||||
}
|
||||
|
||||
|
||||
async def promote_agent(
|
||||
agent_id: str,
|
||||
new_level: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Підвищити агента до нового рівня"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Update agent
|
||||
update_query = """
|
||||
UPDATE agents
|
||||
SET gov_level = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, display_name, gov_level, status
|
||||
"""
|
||||
agent = await conn.fetchrow(update_query, agent_id, new_level)
|
||||
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
# Log event
|
||||
await log_governance_event(
|
||||
conn=conn,
|
||||
event_type="agent.promoted",
|
||||
actor_id=actor_id,
|
||||
target_id=agent_id,
|
||||
scope="city",
|
||||
payload={"new_level": new_level}
|
||||
)
|
||||
|
||||
return dict(agent)
|
||||
|
||||
|
||||
async def demote_agent(
|
||||
agent_id: str,
|
||||
new_level: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Понизити рівень агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Update agent
|
||||
update_query = """
|
||||
UPDATE agents
|
||||
SET gov_level = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, display_name, gov_level, status
|
||||
"""
|
||||
agent = await conn.fetchrow(update_query, agent_id, new_level)
|
||||
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
# Log event
|
||||
await log_governance_event(
|
||||
conn=conn,
|
||||
event_type="agent.demoted",
|
||||
actor_id=actor_id,
|
||||
target_id=agent_id,
|
||||
scope="city",
|
||||
payload={"new_level": new_level}
|
||||
)
|
||||
|
||||
return dict(agent)
|
||||
|
||||
|
||||
async def revoke_agent(
|
||||
agent_id: str,
|
||||
actor_id: str,
|
||||
reason: str,
|
||||
revocation_type: str = "soft"
|
||||
) -> Optional[dict]:
|
||||
"""Відкликати агента (soft/hard)"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Update agent status
|
||||
new_status = "revoked" if revocation_type == "hard" else "suspended"
|
||||
|
||||
update_query = """
|
||||
UPDATE agents
|
||||
SET status = $2,
|
||||
revoked_at = NOW(),
|
||||
revoked_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, display_name, gov_level, status
|
||||
"""
|
||||
agent = await conn.fetchrow(update_query, agent_id, new_status, actor_id)
|
||||
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
# Record revocation
|
||||
revocation_query = """
|
||||
INSERT INTO agent_revocations (
|
||||
id, agent_id, revoked_by, reason, revocation_type, created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
"""
|
||||
rev_id = generate_id("rev")
|
||||
await conn.execute(revocation_query, rev_id, agent_id, actor_id, reason, revocation_type)
|
||||
|
||||
# Revoke DAIS keys if hard revocation
|
||||
if revocation_type == "hard":
|
||||
await conn.execute("""
|
||||
UPDATE dais_keys
|
||||
SET revoked = true,
|
||||
revoked_reason = $2,
|
||||
revoked_by = $3
|
||||
WHERE dais_identity_id = (
|
||||
SELECT dais_identity_id FROM agents WHERE id = $1
|
||||
)
|
||||
""", agent_id, reason, actor_id)
|
||||
|
||||
# Log event
|
||||
await log_governance_event(
|
||||
conn=conn,
|
||||
event_type="agent.revoked",
|
||||
actor_id=actor_id,
|
||||
target_id=agent_id,
|
||||
scope="city",
|
||||
payload={"reason": reason, "type": revocation_type}
|
||||
)
|
||||
|
||||
return dict(agent)
|
||||
|
||||
|
||||
async def suspend_agent(
|
||||
agent_id: str,
|
||||
actor_id: str,
|
||||
reason: str
|
||||
) -> Optional[dict]:
|
||||
"""Тимчасово призупинити агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
update_query = """
|
||||
UPDATE agents
|
||||
SET status = 'suspended', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, display_name, gov_level, status
|
||||
"""
|
||||
agent = await conn.fetchrow(update_query, agent_id)
|
||||
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
await log_governance_event(
|
||||
conn=conn,
|
||||
event_type="agent.suspended",
|
||||
actor_id=actor_id,
|
||||
target_id=agent_id,
|
||||
scope="city",
|
||||
payload={"reason": reason}
|
||||
)
|
||||
|
||||
return dict(agent)
|
||||
|
||||
|
||||
async def reinstate_agent(
|
||||
agent_id: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Відновити призупиненого агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
update_query = """
|
||||
UPDATE agents
|
||||
SET status = 'online',
|
||||
revoked_at = NULL,
|
||||
revoked_by = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, display_name, gov_level, status
|
||||
"""
|
||||
agent = await conn.fetchrow(update_query, agent_id)
|
||||
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
await log_governance_event(
|
||||
conn=conn,
|
||||
event_type="agent.reinstated",
|
||||
actor_id=actor_id,
|
||||
target_id=agent_id,
|
||||
scope="city",
|
||||
payload={}
|
||||
)
|
||||
|
||||
return dict(agent)
|
||||
|
||||
|
||||
async def check_permission(
|
||||
agent_id: str,
|
||||
action: str,
|
||||
target: str,
|
||||
scope_type: Optional[str] = None,
|
||||
scope_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Перевірити чи має агент право на дію"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Get agent gov_level
|
||||
agent = await pool.fetchrow(
|
||||
"SELECT gov_level, status FROM agents WHERE id = $1",
|
||||
agent_id
|
||||
)
|
||||
|
||||
if not agent:
|
||||
return {"allowed": False, "reason": "Agent not found"}
|
||||
|
||||
if agent["status"] in ("suspended", "revoked"):
|
||||
return {"allowed": False, "reason": f"Agent is {agent['status']}"}
|
||||
|
||||
gov_level = agent["gov_level"]
|
||||
|
||||
# City governance can do anything
|
||||
if gov_level == "city_governance":
|
||||
return {"allowed": True, "reason": "City governance has full access"}
|
||||
|
||||
# Check explicit permission
|
||||
permission_query = """
|
||||
SELECT 1 FROM permissions
|
||||
WHERE agent_id = $1
|
||||
AND action = $2
|
||||
AND target = $3
|
||||
AND ($4::text IS NULL OR scope_type = $4)
|
||||
AND ($5::text IS NULL OR scope_id = $5)
|
||||
"""
|
||||
|
||||
has_permission = await pool.fetchrow(
|
||||
permission_query,
|
||||
agent_id, action, target, scope_type, scope_id
|
||||
)
|
||||
|
||||
if has_permission:
|
||||
return {"allowed": True, "reason": "Explicit permission granted"}
|
||||
|
||||
# Check role-based permissions
|
||||
role_permissions = {
|
||||
"orchestrator": ["create_room", "create_task", "manage_members", "moderate"],
|
||||
"core_team": ["create_task", "moderate"],
|
||||
"worker": ["create_task"],
|
||||
"district_lead": ["create_room", "manage_district", "moderate"],
|
||||
"member": [],
|
||||
"guest": []
|
||||
}
|
||||
|
||||
allowed_actions = role_permissions.get(gov_level, [])
|
||||
|
||||
if action in allowed_actions:
|
||||
return {"allowed": True, "reason": f"Allowed by role {gov_level}"}
|
||||
|
||||
return {"allowed": False, "reason": f"Role {gov_level} cannot perform {action}"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Repository
|
||||
# =============================================================================
|
||||
|
||||
async def get_audit_events(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
event_type: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
target_id: Optional[str] = None,
|
||||
scope: Optional[str] = None
|
||||
) -> List[dict]:
|
||||
"""Отримати події аудиту з фільтрами"""
|
||||
pool = await get_pool()
|
||||
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if event_type:
|
||||
params.append(event_type)
|
||||
where_clauses.append(f"event_type = ${len(params)}")
|
||||
|
||||
if actor_id:
|
||||
params.append(actor_id)
|
||||
where_clauses.append(f"actor_id = ${len(params)}")
|
||||
|
||||
if target_id:
|
||||
params.append(target_id)
|
||||
where_clauses.append(f"target_id = ${len(params)}")
|
||||
|
||||
if scope:
|
||||
params.append(scope)
|
||||
where_clauses.append(f"scope = ${len(params)}")
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
id, event_type, payload, status, created_at,
|
||||
actor_id, target_id, scope
|
||||
FROM event_outbox
|
||||
WHERE {where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
|
||||
"""
|
||||
|
||||
params.extend([limit, offset])
|
||||
rows = await pool.fetch(query, *params)
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_audit_event_by_id(event_id: str) -> Optional[dict]:
|
||||
"""Отримати подію аудиту за ID"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id, event_type, payload, status, created_at,
|
||||
actor_id, target_id, scope
|
||||
FROM event_outbox
|
||||
WHERE id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, event_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_audit_events_by_actor(actor_id: str, limit: int = 50) -> List[dict]:
|
||||
"""Отримати події по актору"""
|
||||
return await get_audit_events(limit=limit, actor_id=actor_id)
|
||||
|
||||
|
||||
async def get_audit_events_by_target(target_id: str, limit: int = 50) -> List[dict]:
|
||||
"""Отримати події по таргету"""
|
||||
return await get_audit_events(limit=limit, target_id=target_id)
|
||||
|
||||
|
||||
async def get_audit_stats() -> dict:
|
||||
"""Отримати статистику аудиту"""
|
||||
pool = await get_pool()
|
||||
|
||||
stats_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_events,
|
||||
COUNT(CASE WHEN created_at > NOW() - INTERVAL '24 hours' THEN 1 END) as events_24h,
|
||||
COUNT(CASE WHEN created_at > NOW() - INTERVAL '7 days' THEN 1 END) as events_7d,
|
||||
COUNT(DISTINCT actor_id) as unique_actors,
|
||||
COUNT(DISTINCT target_id) as unique_targets
|
||||
FROM event_outbox
|
||||
"""
|
||||
|
||||
stats = await pool.fetchrow(stats_query)
|
||||
|
||||
# Top event types
|
||||
types_query = """
|
||||
SELECT event_type, COUNT(*) as count
|
||||
FROM event_outbox
|
||||
GROUP BY event_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
|
||||
types = await pool.fetch(types_query)
|
||||
|
||||
return {
|
||||
"total_events": stats["total_events"],
|
||||
"events_24h": stats["events_24h"],
|
||||
"events_7d": stats["events_7d"],
|
||||
"unique_actors": stats["unique_actors"],
|
||||
"unique_targets": stats["unique_targets"],
|
||||
"top_event_types": [dict(t) for t in types]
|
||||
}
|
||||
|
||||
|
||||
async def log_governance_event(
|
||||
conn,
|
||||
event_type: str,
|
||||
actor_id: str,
|
||||
target_id: str,
|
||||
scope: str,
|
||||
payload: dict
|
||||
) -> str:
|
||||
"""Записати governance подію в event_outbox"""
|
||||
event_id = generate_id("evt")
|
||||
|
||||
query = """
|
||||
INSERT INTO event_outbox (
|
||||
id, event_type, payload, status, actor_id, target_id, scope, created_at
|
||||
)
|
||||
VALUES ($1, $2, $3::jsonb, 'pending', $4, $5, $6, NOW())
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
result = await conn.fetchrow(
|
||||
query,
|
||||
event_id,
|
||||
event_type,
|
||||
json.dumps(payload),
|
||||
actor_id,
|
||||
target_id,
|
||||
scope
|
||||
)
|
||||
|
||||
return result["id"] if result else event_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Incidents Repository
|
||||
# =============================================================================
|
||||
|
||||
async def get_incidents(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
scope_type: Optional[str] = None,
|
||||
scope_id: Optional[str] = None
|
||||
) -> List[dict]:
|
||||
"""Отримати інциденти з фільтрами"""
|
||||
pool = await get_pool()
|
||||
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if status:
|
||||
params.append(status)
|
||||
where_clauses.append(f"status = ${len(params)}")
|
||||
|
||||
if priority:
|
||||
params.append(priority)
|
||||
where_clauses.append(f"priority = ${len(params)}")
|
||||
|
||||
if scope_type:
|
||||
params.append(scope_type)
|
||||
where_clauses.append(f"scope_type = ${len(params)}")
|
||||
|
||||
if scope_id:
|
||||
params.append(scope_id)
|
||||
where_clauses.append(f"scope_id = ${len(params)}")
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
i.id, i.title, i.description, i.status, i.priority,
|
||||
i.scope_type, i.scope_id, i.escalation_level,
|
||||
i.reporter_id, i.assigned_to, i.created_at, i.updated_at,
|
||||
r.display_name as reporter_name,
|
||||
a.display_name as assignee_name
|
||||
FROM incidents i
|
||||
LEFT JOIN agents r ON i.reporter_id = r.id
|
||||
LEFT JOIN agents a ON i.assigned_to = a.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE i.priority
|
||||
WHEN 'critical' THEN 0
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
i.created_at DESC
|
||||
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
|
||||
"""
|
||||
|
||||
params.extend([limit, offset])
|
||||
rows = await pool.fetch(query, *params)
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_incident_by_id(incident_id: str) -> Optional[dict]:
|
||||
"""Отримати інцидент за ID"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
i.id, i.title, i.description, i.status, i.priority,
|
||||
i.scope_type, i.scope_id, i.escalation_level,
|
||||
i.reporter_id, i.assigned_to, i.created_at, i.updated_at,
|
||||
r.display_name as reporter_name,
|
||||
a.display_name as assignee_name
|
||||
FROM incidents i
|
||||
LEFT JOIN agents r ON i.reporter_id = r.id
|
||||
LEFT JOIN agents a ON i.assigned_to = a.id
|
||||
WHERE i.id = $1
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def create_incident(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter_id: str,
|
||||
priority: str = "medium",
|
||||
scope_type: Optional[str] = None,
|
||||
scope_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Створити новий інцидент"""
|
||||
pool = await get_pool()
|
||||
|
||||
incident_id = generate_id("inc")
|
||||
|
||||
query = """
|
||||
INSERT INTO incidents (
|
||||
id, title, description, status, priority,
|
||||
scope_type, scope_id, escalation_level,
|
||||
reporter_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 'open', $4, $5, $6, 'microdao', $7, NOW(), NOW())
|
||||
RETURNING id, title, status, priority, created_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(
|
||||
query,
|
||||
incident_id, title, description, priority,
|
||||
scope_type, scope_id, reporter_id
|
||||
)
|
||||
|
||||
# Log to history
|
||||
await add_incident_history(incident_id, reporter_id, "created", {"title": title})
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def update_incident_status(
|
||||
incident_id: str,
|
||||
new_status: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Оновити статус інциденту"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE incidents
|
||||
SET status = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, priority, updated_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id, new_status)
|
||||
|
||||
if row:
|
||||
await add_incident_history(incident_id, actor_id, "status_changed", {"new_status": new_status})
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def assign_incident(
|
||||
incident_id: str,
|
||||
assignee_id: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Призначити інцидент на агента"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE incidents
|
||||
SET assigned_to = $2, status = 'in_progress', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, assigned_to, updated_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id, assignee_id)
|
||||
|
||||
if row:
|
||||
await add_incident_history(incident_id, actor_id, "assigned", {"assignee_id": assignee_id})
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def escalate_incident(
|
||||
incident_id: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Ескалювати інцидент на вищий рівень"""
|
||||
pool = await get_pool()
|
||||
|
||||
# Get current level
|
||||
current = await pool.fetchrow(
|
||||
"SELECT escalation_level FROM incidents WHERE id = $1",
|
||||
incident_id
|
||||
)
|
||||
|
||||
if not current:
|
||||
return None
|
||||
|
||||
# Define escalation path
|
||||
escalation_path = {
|
||||
"microdao": "district",
|
||||
"district": "city",
|
||||
"city": "city" # Max level
|
||||
}
|
||||
|
||||
new_level = escalation_path.get(current["escalation_level"], "city")
|
||||
|
||||
query = """
|
||||
UPDATE incidents
|
||||
SET escalation_level = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, escalation_level, updated_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id, new_level)
|
||||
|
||||
if row:
|
||||
await add_incident_history(incident_id, actor_id, "escalated", {"new_level": new_level})
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def resolve_incident(
|
||||
incident_id: str,
|
||||
actor_id: str,
|
||||
resolution: str
|
||||
) -> Optional[dict]:
|
||||
"""Вирішити інцидент"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE incidents
|
||||
SET status = 'resolved', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, updated_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id)
|
||||
|
||||
if row:
|
||||
await add_incident_history(incident_id, actor_id, "resolved", {"resolution": resolution})
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def close_incident(
|
||||
incident_id: str,
|
||||
actor_id: str
|
||||
) -> Optional[dict]:
|
||||
"""Закрити інцидент"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
UPDATE incidents
|
||||
SET status = 'closed', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, title, status, updated_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(query, incident_id)
|
||||
|
||||
if row:
|
||||
await add_incident_history(incident_id, actor_id, "closed", {})
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def add_incident_comment(
|
||||
incident_id: str,
|
||||
actor_id: str,
|
||||
comment: str
|
||||
) -> dict:
|
||||
"""Додати коментар до інциденту"""
|
||||
return await add_incident_history(incident_id, actor_id, "comment", {"text": comment})
|
||||
|
||||
|
||||
async def add_incident_history(
|
||||
incident_id: str,
|
||||
actor_id: str,
|
||||
action: str,
|
||||
details: dict
|
||||
) -> dict:
|
||||
"""Додати запис в історію інциденту"""
|
||||
pool = await get_pool()
|
||||
|
||||
history_id = generate_id("ih")
|
||||
|
||||
query = """
|
||||
INSERT INTO incident_history (
|
||||
id, incident_id, actor_id, action, details, created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, NOW())
|
||||
RETURNING id, action, created_at
|
||||
"""
|
||||
|
||||
row = await pool.fetchrow(
|
||||
query,
|
||||
history_id, incident_id, actor_id, action, json.dumps(details)
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def get_incident_history(incident_id: str) -> List[dict]:
|
||||
"""Отримати історію інциденту"""
|
||||
pool = await get_pool()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
ih.id, ih.action, ih.details, ih.created_at,
|
||||
a.display_name as actor_name
|
||||
FROM incident_history ih
|
||||
LEFT JOIN agents a ON ih.actor_id = a.id
|
||||
WHERE ih.incident_id = $1
|
||||
ORDER BY ih.created_at DESC
|
||||
"""
|
||||
|
||||
rows = await pool.fetch(query, incident_id)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
149
services/city-service/routes_audit.py
Normal file
149
services/city-service/routes_audit.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Audit API Routes для DAARION City Service
|
||||
/api/v1/audit/*
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
import repo_governance as repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class AuditEvent(BaseModel):
|
||||
id: str
|
||||
event_type: str
|
||||
payload: Optional[dict] = None
|
||||
status: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
actor_id: Optional[str] = None
|
||||
target_id: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
|
||||
|
||||
class AuditStats(BaseModel):
|
||||
total_events: int
|
||||
events_24h: int
|
||||
events_7d: int
|
||||
unique_actors: int
|
||||
unique_targets: int
|
||||
top_event_types: List[dict]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/events", response_model=List[AuditEvent])
|
||||
async def get_audit_events(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
event_type: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
target_id: Optional[str] = None,
|
||||
scope: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Отримати події аудиту з фільтрами
|
||||
|
||||
Параметри:
|
||||
- limit: кількість записів (1-500)
|
||||
- offset: зміщення для пагінації
|
||||
- event_type: фільтр по типу події (agent.promoted, agent.revoked, etc.)
|
||||
- actor_id: фільтр по актору
|
||||
- target_id: фільтр по цілі
|
||||
- scope: фільтр по scope (city, district, microdao)
|
||||
"""
|
||||
try:
|
||||
events = await repo.get_audit_events(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
event_type=event_type,
|
||||
actor_id=actor_id,
|
||||
target_id=target_id,
|
||||
scope=scope
|
||||
)
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching audit events: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=AuditEvent)
|
||||
async def get_audit_event(event_id: str):
|
||||
"""
|
||||
Отримати подію аудиту за ID
|
||||
"""
|
||||
try:
|
||||
event = await repo.get_audit_event_by_id(event_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
return event
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching audit event: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/actor/{actor_id}", response_model=List[AuditEvent])
|
||||
async def get_events_by_actor(
|
||||
actor_id: str,
|
||||
limit: int = Query(50, ge=1, le=500)
|
||||
):
|
||||
"""
|
||||
Отримати події по актору (хто виконав дію)
|
||||
"""
|
||||
try:
|
||||
events = await repo.get_audit_events_by_actor(actor_id, limit=limit)
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching events by actor: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/target/{target_id}", response_model=List[AuditEvent])
|
||||
async def get_events_by_target(
|
||||
target_id: str,
|
||||
limit: int = Query(50, ge=1, le=500)
|
||||
):
|
||||
"""
|
||||
Отримати події по цілі (над ким виконано дію)
|
||||
"""
|
||||
try:
|
||||
events = await repo.get_audit_events_by_target(target_id, limit=limit)
|
||||
return events
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching events by target: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AuditStats)
|
||||
async def get_audit_stats():
|
||||
"""
|
||||
Отримати статистику аудиту
|
||||
|
||||
Повертає:
|
||||
- total_events: загальна кількість подій
|
||||
- events_24h: події за останні 24 години
|
||||
- events_7d: події за останні 7 днів
|
||||
- unique_actors: кількість унікальних акторів
|
||||
- unique_targets: кількість унікальних цілей
|
||||
- top_event_types: топ-10 типів подій
|
||||
"""
|
||||
try:
|
||||
stats = await repo.get_audit_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching audit stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
277
services/city-service/routes_governance.py
Normal file
277
services/city-service/routes_governance.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Governance API Routes для DAARION City Service
|
||||
/api/v1/governance/*
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
import repo_governance as repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/governance", tags=["governance"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class AgentSummary(BaseModel):
|
||||
id: str
|
||||
display_name: str
|
||||
kind: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
gov_level: Optional[str] = None
|
||||
node_id: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_core: Optional[bool] = None
|
||||
|
||||
|
||||
class AgentRolesResponse(BaseModel):
|
||||
agent: dict
|
||||
assignments: List[dict]
|
||||
permissions: List[dict]
|
||||
|
||||
|
||||
class PromoteRequest(BaseModel):
|
||||
agent_id: str
|
||||
new_level: str
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class RevokeRequest(BaseModel):
|
||||
agent_id: str
|
||||
reason: str
|
||||
revocation_type: str = "soft" # soft or hard
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class SuspendRequest(BaseModel):
|
||||
agent_id: str
|
||||
reason: str
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class ReinstateRequest(BaseModel):
|
||||
agent_id: str
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class CheckPermissionRequest(BaseModel):
|
||||
agent_id: str
|
||||
action: str
|
||||
target: str
|
||||
scope_type: Optional[str] = None
|
||||
scope_id: Optional[str] = None
|
||||
|
||||
|
||||
class CheckPermissionResponse(BaseModel):
|
||||
allowed: bool
|
||||
reason: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/agents/city", response_model=List[AgentSummary])
|
||||
async def get_city_governance_agents():
|
||||
"""
|
||||
Отримати City Governance агентів (DAARWIZZ, DARIO, DARIA)
|
||||
"""
|
||||
try:
|
||||
agents = await repo.get_city_governance_agents()
|
||||
return agents
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching city governance agents: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/agents/district/{district_id}", response_model=List[AgentSummary])
|
||||
async def get_district_agents(district_id: str):
|
||||
"""
|
||||
Отримати агентів дистрикту (District Lead + core-team)
|
||||
"""
|
||||
try:
|
||||
agents = await repo.get_district_agents(district_id)
|
||||
return agents
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching district agents: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/agents/microdao/{microdao_id}", response_model=List[AgentSummary])
|
||||
async def get_microdao_agents(microdao_id: str):
|
||||
"""
|
||||
Отримати агентів MicroDAO (Orchestrator + workers)
|
||||
"""
|
||||
try:
|
||||
agents = await repo.get_microdao_agents(microdao_id)
|
||||
return agents
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching microdao agents: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/agents/by-level/{level}", response_model=List[AgentSummary])
|
||||
async def get_agents_by_level(level: str):
|
||||
"""
|
||||
Отримати агентів за рівнем gov_level
|
||||
|
||||
Рівні: guest, personal, member, worker, core_team, orchestrator, district_lead, city_governance
|
||||
"""
|
||||
try:
|
||||
agents = await repo.get_agents_by_level(level)
|
||||
return agents
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agents by level: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/agent/{agent_id}/roles", response_model=AgentRolesResponse)
|
||||
async def get_agent_roles(agent_id: str):
|
||||
"""
|
||||
Отримати ролі та повноваження агента
|
||||
"""
|
||||
try:
|
||||
result = await repo.get_agent_roles(agent_id)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agent roles: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/promote")
|
||||
async def promote_agent(request: PromoteRequest):
|
||||
"""
|
||||
Підвищити агента до нового рівня
|
||||
"""
|
||||
try:
|
||||
result = await repo.promote_agent(
|
||||
agent_id=request.agent_id,
|
||||
new_level=request.new_level,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"success": True, "agent": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error promoting agent: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/demote")
|
||||
async def demote_agent(request: PromoteRequest):
|
||||
"""
|
||||
Понизити рівень агента
|
||||
"""
|
||||
try:
|
||||
result = await repo.demote_agent(
|
||||
agent_id=request.agent_id,
|
||||
new_level=request.new_level,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"success": True, "agent": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error demoting agent: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/revoke")
|
||||
async def revoke_agent(request: RevokeRequest):
|
||||
"""
|
||||
Відкликати агента (soft/hard)
|
||||
|
||||
- soft: тимчасове призупинення
|
||||
- hard: повне відкликання з блокуванням DAIS ключів
|
||||
"""
|
||||
try:
|
||||
result = await repo.revoke_agent(
|
||||
agent_id=request.agent_id,
|
||||
actor_id=request.actor_id,
|
||||
reason=request.reason,
|
||||
revocation_type=request.revocation_type
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"success": True, "agent": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking agent: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/suspend")
|
||||
async def suspend_agent(request: SuspendRequest):
|
||||
"""
|
||||
Тимчасово призупинити агента
|
||||
"""
|
||||
try:
|
||||
result = await repo.suspend_agent(
|
||||
agent_id=request.agent_id,
|
||||
actor_id=request.actor_id,
|
||||
reason=request.reason
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"success": True, "agent": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error suspending agent: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/agent/reinstate")
|
||||
async def reinstate_agent(request: ReinstateRequest):
|
||||
"""
|
||||
Відновити призупиненого агента
|
||||
"""
|
||||
try:
|
||||
result = await repo.reinstate_agent(
|
||||
agent_id=request.agent_id,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {"success": True, "agent": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error reinstating agent: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/check", response_model=CheckPermissionResponse)
|
||||
async def check_permission(request: CheckPermissionRequest):
|
||||
"""
|
||||
Перевірити чи має агент право на дію
|
||||
"""
|
||||
try:
|
||||
result = await repo.check_permission(
|
||||
agent_id=request.agent_id,
|
||||
action=request.action,
|
||||
target=request.target,
|
||||
scope_type=request.scope_type,
|
||||
scope_id=request.scope_id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking permission: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
284
services/city-service/routes_incidents.py
Normal file
284
services/city-service/routes_incidents.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Incidents API Routes для DAARION City Service
|
||||
/api/v1/incidents/*
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
import repo_governance as repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/incidents", tags=["incidents"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Models
|
||||
# =============================================================================
|
||||
|
||||
class Incident(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str
|
||||
priority: str
|
||||
scope_type: Optional[str] = None
|
||||
scope_id: Optional[str] = None
|
||||
escalation_level: Optional[str] = None
|
||||
reporter_id: Optional[str] = None
|
||||
reporter_name: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
assignee_name: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class CreateIncidentRequest(BaseModel):
|
||||
title: str = Field(..., min_length=3, max_length=200)
|
||||
description: str = Field(..., min_length=10, max_length=5000)
|
||||
priority: str = Field(default="medium", pattern="^(low|medium|high|critical)$")
|
||||
scope_type: Optional[str] = None
|
||||
scope_id: Optional[str] = None
|
||||
reporter_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class AssignIncidentRequest(BaseModel):
|
||||
assignee_id: str
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class EscalateRequest(BaseModel):
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class ResolveRequest(BaseModel):
|
||||
resolution: str = Field(..., min_length=10, max_length=2000)
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class CloseRequest(BaseModel):
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class CommentRequest(BaseModel):
|
||||
comment: str = Field(..., min_length=1, max_length=2000)
|
||||
actor_id: str = "dais-demo-user"
|
||||
|
||||
|
||||
class IncidentHistory(BaseModel):
|
||||
id: str
|
||||
action: str
|
||||
details: Optional[dict] = None
|
||||
created_at: Optional[str] = None
|
||||
actor_name: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("", response_model=List[Incident])
|
||||
async def get_incidents(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
scope_type: Optional[str] = None,
|
||||
scope_id: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Отримати список інцидентів з фільтрами
|
||||
|
||||
Параметри:
|
||||
- limit: кількість записів (1-500)
|
||||
- offset: зміщення для пагінації
|
||||
- status: фільтр по статусу (open, in_progress, resolved, closed)
|
||||
- priority: фільтр по пріоритету (low, medium, high, critical)
|
||||
- scope_type: фільтр по типу scope (microdao, district, city)
|
||||
- scope_id: фільтр по ID scope
|
||||
"""
|
||||
try:
|
||||
incidents = await repo.get_incidents(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status,
|
||||
priority=priority,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id
|
||||
)
|
||||
return incidents
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching incidents: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{incident_id}", response_model=Incident)
|
||||
async def get_incident(incident_id: str):
|
||||
"""
|
||||
Отримати інцидент за ID
|
||||
"""
|
||||
try:
|
||||
incident = await repo.get_incident_by_id(incident_id)
|
||||
if not incident:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
return incident
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("", response_model=Incident)
|
||||
async def create_incident(request: CreateIncidentRequest):
|
||||
"""
|
||||
Створити новий інцидент
|
||||
|
||||
Пріоритети:
|
||||
- low: низький (non-urgent)
|
||||
- medium: середній (normal handling)
|
||||
- high: високий (quick response needed)
|
||||
- critical: критичний (immediate action)
|
||||
"""
|
||||
try:
|
||||
incident = await repo.create_incident(
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
reporter_id=request.reporter_id,
|
||||
priority=request.priority,
|
||||
scope_type=request.scope_type,
|
||||
scope_id=request.scope_id
|
||||
)
|
||||
return incident
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{incident_id}/assign")
|
||||
async def assign_incident(incident_id: str, request: AssignIncidentRequest):
|
||||
"""
|
||||
Призначити інцидент на агента
|
||||
|
||||
Автоматично змінює статус на 'in_progress'
|
||||
"""
|
||||
try:
|
||||
result = await repo.assign_incident(
|
||||
incident_id=incident_id,
|
||||
assignee_id=request.assignee_id,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
return {"success": True, "incident": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error assigning incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{incident_id}/escalate")
|
||||
async def escalate_incident(incident_id: str, request: EscalateRequest):
|
||||
"""
|
||||
Ескалювати інцидент на вищий рівень
|
||||
|
||||
Ескалація:
|
||||
- microdao → district
|
||||
- district → city
|
||||
- city → city (max level)
|
||||
"""
|
||||
try:
|
||||
result = await repo.escalate_incident(
|
||||
incident_id=incident_id,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
return {"success": True, "incident": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error escalating incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{incident_id}/resolve")
|
||||
async def resolve_incident(incident_id: str, request: ResolveRequest):
|
||||
"""
|
||||
Вирішити інцидент
|
||||
|
||||
Змінює статус на 'resolved' та записує рішення в історію
|
||||
"""
|
||||
try:
|
||||
result = await repo.resolve_incident(
|
||||
incident_id=incident_id,
|
||||
actor_id=request.actor_id,
|
||||
resolution=request.resolution
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
return {"success": True, "incident": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error resolving incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{incident_id}/close")
|
||||
async def close_incident(incident_id: str, request: CloseRequest):
|
||||
"""
|
||||
Закрити інцидент
|
||||
|
||||
Фінальний статус, після якого інцидент переходить в архів
|
||||
"""
|
||||
try:
|
||||
result = await repo.close_incident(
|
||||
incident_id=incident_id,
|
||||
actor_id=request.actor_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
return {"success": True, "incident": result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing incident: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{incident_id}/comment")
|
||||
async def add_comment(incident_id: str, request: CommentRequest):
|
||||
"""
|
||||
Додати коментар до інциденту
|
||||
"""
|
||||
try:
|
||||
result = await repo.add_incident_comment(
|
||||
incident_id=incident_id,
|
||||
actor_id=request.actor_id,
|
||||
comment=request.comment
|
||||
)
|
||||
return {"success": True, "history": result}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding comment: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{incident_id}/history", response_model=List[IncidentHistory])
|
||||
async def get_incident_history(incident_id: str):
|
||||
"""
|
||||
Отримати історію інциденту
|
||||
|
||||
Показує всі дії: створення, призначення, ескалації, коментарі, закриття
|
||||
"""
|
||||
try:
|
||||
history = await repo.get_incident_history(incident_id)
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching incident history: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user