v1.5.0: IRM — инциденты, задачи, эскалации
- docs/IRM.md; Alembic 002: incidents, tasks, escalation_policies - Модули incidents/tasks/escalations: API, UI, register_events(bus, pool) - Авто-инцидент из alert.received; тесты test_irm_modules.py Made-with: Cursor
This commit is contained in:
159
onguard24/modules/tasks.py
Normal file
159
onguard24/modules/tasks.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""IRM: задачи по инцидентам (или вне привязки)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from onguard24.deps import get_pool
|
||||
from onguard24.domain.events import EventBus
|
||||
from onguard24.modules.ui_support import wrap_module_html_page
|
||||
|
||||
router = APIRouter(tags=["module-tasks"])
|
||||
ui_router = APIRouter(tags=["web-tasks"], include_in_schema=False)
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
incident_id: UUID | None = None
|
||||
|
||||
|
||||
def register_events(_bus: EventBus, _pool: asyncpg.Pool | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def render_home_fragment(request: Request) -> str:
|
||||
pool = get_pool(request)
|
||||
if pool is None:
|
||||
return '<p class="module-note">Нужна БД для задач.</p>'
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
n = await conn.fetchval("SELECT count(*)::int FROM tasks")
|
||||
except Exception:
|
||||
return '<p class="module-note">Таблица задач недоступна (миграции?).</p>'
|
||||
return f'<div class="module-fragment"><p>Задач: <strong>{int(n)}</strong></p></div>'
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_tasks_api(
|
||||
pool: asyncpg.Pool | None = Depends(get_pool),
|
||||
incident_id: UUID | None = None,
|
||||
limit: int = 100,
|
||||
):
|
||||
if pool is None:
|
||||
return {"items": [], "database": "disabled"}
|
||||
limit = min(max(limit, 1), 200)
|
||||
async with pool.acquire() as conn:
|
||||
if incident_id:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, incident_id, title, status, created_at
|
||||
FROM tasks WHERE incident_id = $1::uuid
|
||||
ORDER BY created_at DESC LIMIT $2
|
||||
""",
|
||||
incident_id,
|
||||
limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, incident_id, title, status, created_at
|
||||
FROM tasks
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"incident_id": str(r["incident_id"]) if r["incident_id"] else None,
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
}
|
||||
)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
async def create_task_api(body: TaskCreate, pool: asyncpg.Pool | None = Depends(get_pool)):
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="database disabled")
|
||||
if body.incident_id:
|
||||
async with pool.acquire() as conn:
|
||||
ok = await conn.fetchval(
|
||||
"SELECT 1 FROM incidents WHERE id = $1::uuid",
|
||||
body.incident_id,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="incident not found")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tasks (title, incident_id, status)
|
||||
VALUES ($1, $2::uuid, 'open')
|
||||
RETURNING id, incident_id, title, status, created_at
|
||||
""",
|
||||
body.title.strip(),
|
||||
body.incident_id,
|
||||
)
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"incident_id": str(row["incident_id"]) if row["incident_id"] else None,
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
@ui_router.get("/", response_class=HTMLResponse)
|
||||
async def tasks_ui_home(request: Request):
|
||||
pool = get_pool(request)
|
||||
rows_html = ""
|
||||
err = ""
|
||||
if pool is None:
|
||||
err = "<p>База данных не настроена.</p>"
|
||||
else:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT t.id, t.title, t.status, t.incident_id, t.created_at
|
||||
FROM tasks t
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
for r in rows:
|
||||
iid = str(r["incident_id"])[:8] + "…" if r["incident_id"] else "—"
|
||||
rows_html += (
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(r['id']))[:8]}…</td>"
|
||||
f"<td>{html.escape(r['title'])}</td>"
|
||||
f"<td>{html.escape(r['status'])}</td>"
|
||||
f"<td>{html.escape(iid)}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
except Exception as e:
|
||||
err = f"<p class=\"module-err\">{html.escape(str(e))}</p>"
|
||||
inner = f"""<h1>Задачи</h1>
|
||||
{err}
|
||||
<table class="irm-table">
|
||||
<thead><tr><th>ID</th><th>Заголовок</th><th>Статус</th><th>Инцидент</th></tr></thead>
|
||||
<tbody>{rows_html or '<tr><td colspan="4">Пока нет задач</td></tr>'}</tbody>
|
||||
</table>"""
|
||||
return HTMLResponse(
|
||||
wrap_module_html_page(
|
||||
document_title="Задачи — onGuard24",
|
||||
current_slug="tasks",
|
||||
main_inner_html=inner,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user