kairo-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- image-service/main.py +178 -0
- infra/chat/app/main.py +84 -0
- kairo/backend/__init__.py +0 -0
- kairo/backend/api/__init__.py +0 -0
- kairo/backend/api/admin/__init__.py +23 -0
- kairo/backend/api/admin/audit.py +54 -0
- kairo/backend/api/admin/content.py +142 -0
- kairo/backend/api/admin/incidents.py +148 -0
- kairo/backend/api/admin/stats.py +125 -0
- kairo/backend/api/admin/system.py +87 -0
- kairo/backend/api/admin/users.py +279 -0
- kairo/backend/api/agents.py +94 -0
- kairo/backend/api/api_keys.py +85 -0
- kairo/backend/api/auth.py +116 -0
- kairo/backend/api/billing.py +41 -0
- kairo/backend/api/chat.py +72 -0
- kairo/backend/api/conversations.py +125 -0
- kairo/backend/api/device_auth.py +100 -0
- kairo/backend/api/files.py +83 -0
- kairo/backend/api/health.py +36 -0
- kairo/backend/api/images.py +80 -0
- kairo/backend/api/openai_compat.py +225 -0
- kairo/backend/api/projects.py +102 -0
- kairo/backend/api/usage.py +32 -0
- kairo/backend/api/webhooks.py +79 -0
- kairo/backend/app.py +297 -0
- kairo/backend/config.py +179 -0
- kairo/backend/core/__init__.py +0 -0
- kairo/backend/core/admin_auth.py +24 -0
- kairo/backend/core/api_key_auth.py +55 -0
- kairo/backend/core/database.py +28 -0
- kairo/backend/core/dependencies.py +70 -0
- kairo/backend/core/logging.py +23 -0
- kairo/backend/core/rate_limit.py +73 -0
- kairo/backend/core/security.py +29 -0
- kairo/backend/models/__init__.py +19 -0
- kairo/backend/models/agent.py +30 -0
- kairo/backend/models/api_key.py +25 -0
- kairo/backend/models/api_usage.py +29 -0
- kairo/backend/models/audit_log.py +26 -0
- kairo/backend/models/conversation.py +48 -0
- kairo/backend/models/device_code.py +30 -0
- kairo/backend/models/feature_flag.py +21 -0
- kairo/backend/models/image_generation.py +24 -0
- kairo/backend/models/incident.py +28 -0
- kairo/backend/models/project.py +28 -0
- kairo/backend/models/uptime_record.py +24 -0
- kairo/backend/models/usage.py +24 -0
- kairo/backend/models/user.py +49 -0
- kairo/backend/schemas/__init__.py +0 -0
- kairo/backend/schemas/admin/__init__.py +0 -0
- kairo/backend/schemas/admin/audit.py +28 -0
- kairo/backend/schemas/admin/content.py +53 -0
- kairo/backend/schemas/admin/stats.py +77 -0
- kairo/backend/schemas/admin/system.py +44 -0
- kairo/backend/schemas/admin/users.py +48 -0
- kairo/backend/schemas/agent.py +42 -0
- kairo/backend/schemas/api_key.py +30 -0
- kairo/backend/schemas/auth.py +57 -0
- kairo/backend/schemas/chat.py +26 -0
- kairo/backend/schemas/conversation.py +39 -0
- kairo/backend/schemas/device_auth.py +40 -0
- kairo/backend/schemas/image.py +15 -0
- kairo/backend/schemas/openai_compat.py +76 -0
- kairo/backend/schemas/project.py +21 -0
- kairo/backend/schemas/status.py +81 -0
- kairo/backend/schemas/usage.py +15 -0
- kairo/backend/services/__init__.py +0 -0
- kairo/backend/services/admin/__init__.py +0 -0
- kairo/backend/services/admin/audit_service.py +78 -0
- kairo/backend/services/admin/content_service.py +119 -0
- kairo/backend/services/admin/incident_service.py +94 -0
- kairo/backend/services/admin/stats_service.py +281 -0
- kairo/backend/services/admin/system_service.py +126 -0
- kairo/backend/services/admin/user_service.py +157 -0
- kairo/backend/services/agent_service.py +107 -0
- kairo/backend/services/api_key_service.py +66 -0
- kairo/backend/services/api_usage_service.py +126 -0
- kairo/backend/services/auth_service.py +101 -0
- kairo/backend/services/chat_service.py +501 -0
- kairo/backend/services/conversation_service.py +264 -0
- kairo/backend/services/device_auth_service.py +193 -0
- kairo/backend/services/email_service.py +55 -0
- kairo/backend/services/image_service.py +181 -0
- kairo/backend/services/llm_service.py +186 -0
- kairo/backend/services/project_service.py +109 -0
- kairo/backend/services/status_service.py +167 -0
- kairo/backend/services/stripe_service.py +78 -0
- kairo/backend/services/usage_service.py +150 -0
- kairo/backend/services/web_search_service.py +96 -0
- kairo/migrations/env.py +60 -0
- kairo/migrations/versions/001_initial.py +55 -0
- kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
- kairo/migrations/versions/003_username_to_email.py +21 -0
- kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
- kairo/migrations/versions/005_add_projects.py +52 -0
- kairo/migrations/versions/006_add_image_generation.py +63 -0
- kairo/migrations/versions/007_add_admin_portal.py +107 -0
- kairo/migrations/versions/008_add_device_code_auth.py +76 -0
- kairo/migrations/versions/009_add_status_page.py +65 -0
- kairo/tools/extract_claude_data.py +465 -0
- kairo/tools/filter_claude_data.py +303 -0
- kairo/tools/generate_curated_data.py +157 -0
- kairo/tools/mix_training_data.py +295 -0
- kairo_code/__init__.py +3 -0
- kairo_code/agents/__init__.py +25 -0
- kairo_code/agents/architect.py +98 -0
- kairo_code/agents/audit.py +100 -0
- kairo_code/agents/base.py +463 -0
- kairo_code/agents/coder.py +155 -0
- kairo_code/agents/database.py +77 -0
- kairo_code/agents/docs.py +88 -0
- kairo_code/agents/explorer.py +62 -0
- kairo_code/agents/guardian.py +80 -0
- kairo_code/agents/planner.py +66 -0
- kairo_code/agents/reviewer.py +91 -0
- kairo_code/agents/security.py +94 -0
- kairo_code/agents/terraform.py +88 -0
- kairo_code/agents/testing.py +97 -0
- kairo_code/agents/uiux.py +88 -0
- kairo_code/auth.py +232 -0
- kairo_code/config.py +172 -0
- kairo_code/conversation.py +173 -0
- kairo_code/heartbeat.py +63 -0
- kairo_code/llm.py +291 -0
- kairo_code/logging_config.py +156 -0
- kairo_code/main.py +818 -0
- kairo_code/router.py +217 -0
- kairo_code/sandbox.py +248 -0
- kairo_code/settings.py +183 -0
- kairo_code/tools/__init__.py +51 -0
- kairo_code/tools/analysis.py +509 -0
- kairo_code/tools/base.py +417 -0
- kairo_code/tools/code.py +58 -0
- kairo_code/tools/definitions.py +617 -0
- kairo_code/tools/files.py +315 -0
- kairo_code/tools/review.py +390 -0
- kairo_code/tools/search.py +185 -0
- kairo_code/ui.py +418 -0
- kairo_code-0.1.0.dist-info/METADATA +13 -0
- kairo_code-0.1.0.dist-info/RECORD +144 -0
- kairo_code-0.1.0.dist-info/WHEEL +5 -0
- kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
- kairo_code-0.1.0.dist-info/top_level.txt +4 -0
image-service/main.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Kairo Image Generation Service — FLUX.1-dev on GPU (text2img + img2img)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from diffusers import FluxPipeline, FluxImg2ImgPipeline
|
|
10
|
+
from fastapi import FastAPI, Depends, HTTPException, Header, Form, UploadFile, File
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
from PIL import Image
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(level=logging.INFO)
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
app = FastAPI(title="Kairo Image Service")
|
|
19
|
+
|
|
20
|
+
API_KEY = os.environ.get("IMAGE_API_KEY", "")
|
|
21
|
+
pipe: FluxPipeline | None = None
|
|
22
|
+
img2img_pipe: FluxImg2ImgPipeline | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Schemas ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class GenerateRequest(BaseModel):
|
|
28
|
+
prompt: str = Field(..., min_length=1, max_length=2000)
|
|
29
|
+
width: int = Field(default=1024, ge=256, le=2048)
|
|
30
|
+
height: int = Field(default=1024, ge=256, le=2048)
|
|
31
|
+
num_inference_steps: int = Field(default=25, ge=1, le=50)
|
|
32
|
+
seed: int | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Auth ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async def verify_api_key(authorization: str = Header(default="")):
|
|
38
|
+
if API_KEY and authorization != f"Bearer {API_KEY}":
|
|
39
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Lifecycle ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@app.on_event("startup")
|
|
45
|
+
async def load_model():
|
|
46
|
+
global pipe, img2img_pipe
|
|
47
|
+
logger.info("Loading FLUX.1-dev with sequential CPU offloading...")
|
|
48
|
+
pipe = FluxPipeline.from_pretrained(
|
|
49
|
+
"black-forest-labs/FLUX.1-dev",
|
|
50
|
+
torch_dtype=torch.bfloat16,
|
|
51
|
+
)
|
|
52
|
+
pipe.enable_sequential_cpu_offload()
|
|
53
|
+
|
|
54
|
+
# Create img2img pipeline sharing all model components (zero extra VRAM)
|
|
55
|
+
img2img_pipe = FluxImg2ImgPipeline(
|
|
56
|
+
transformer=pipe.transformer,
|
|
57
|
+
scheduler=pipe.scheduler,
|
|
58
|
+
vae=pipe.vae,
|
|
59
|
+
text_encoder=pipe.text_encoder,
|
|
60
|
+
text_encoder_2=pipe.text_encoder_2,
|
|
61
|
+
tokenizer=pipe.tokenizer,
|
|
62
|
+
tokenizer_2=pipe.tokenizer_2,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("FLUX.1-dev loaded (text2img + img2img ready)")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def _snap_to_multiple(value: int, multiple: int = 16) -> int:
|
|
71
|
+
"""Round to nearest multiple (required by VAE)."""
|
|
72
|
+
return max(multiple, round(value / multiple) * multiple)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_pipeline(prompt: str, width: int, height: int, steps: int, seed: int | None):
|
|
76
|
+
"""Run text2img pipeline synchronously (called via asyncio.to_thread)."""
|
|
77
|
+
generator = (
|
|
78
|
+
torch.Generator("cuda").manual_seed(seed)
|
|
79
|
+
if seed is not None
|
|
80
|
+
else None
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
image = pipe(
|
|
84
|
+
prompt=prompt,
|
|
85
|
+
width=_snap_to_multiple(width),
|
|
86
|
+
height=_snap_to_multiple(height),
|
|
87
|
+
num_inference_steps=steps,
|
|
88
|
+
generator=generator,
|
|
89
|
+
).images[0]
|
|
90
|
+
buf = BytesIO()
|
|
91
|
+
image.save(buf, format="PNG")
|
|
92
|
+
buf.seek(0)
|
|
93
|
+
return buf
|
|
94
|
+
finally:
|
|
95
|
+
torch.cuda.empty_cache()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_img2img_pipeline(
|
|
99
|
+
image: Image.Image,
|
|
100
|
+
prompt: str,
|
|
101
|
+
strength: float,
|
|
102
|
+
steps: int,
|
|
103
|
+
seed: int | None,
|
|
104
|
+
):
|
|
105
|
+
"""Run img2img pipeline synchronously (called via asyncio.to_thread)."""
|
|
106
|
+
generator = (
|
|
107
|
+
torch.Generator("cuda").manual_seed(seed)
|
|
108
|
+
if seed is not None
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
# Resize to multiples of 16
|
|
113
|
+
w = _snap_to_multiple(image.width)
|
|
114
|
+
h = _snap_to_multiple(image.height)
|
|
115
|
+
if (w, h) != image.size:
|
|
116
|
+
image = image.resize((w, h), Image.LANCZOS)
|
|
117
|
+
|
|
118
|
+
result = img2img_pipe(
|
|
119
|
+
prompt=prompt,
|
|
120
|
+
image=image,
|
|
121
|
+
strength=strength,
|
|
122
|
+
num_inference_steps=steps,
|
|
123
|
+
generator=generator,
|
|
124
|
+
).images[0]
|
|
125
|
+
buf = BytesIO()
|
|
126
|
+
result.save(buf, format="PNG")
|
|
127
|
+
buf.seek(0)
|
|
128
|
+
return buf
|
|
129
|
+
finally:
|
|
130
|
+
torch.cuda.empty_cache()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Routes ───────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
@app.get("/health")
|
|
136
|
+
async def health():
|
|
137
|
+
return {
|
|
138
|
+
"status": "ok" if pipe is not None else "loading",
|
|
139
|
+
"model": "flux-dev",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.post("/generate", dependencies=[Depends(verify_api_key)])
|
|
144
|
+
async def generate(req: GenerateRequest):
|
|
145
|
+
if pipe is None:
|
|
146
|
+
raise HTTPException(status_code=503, detail="Model is still loading")
|
|
147
|
+
|
|
148
|
+
logger.info("text2img: %s", req.prompt[:80])
|
|
149
|
+
buf = await asyncio.to_thread(
|
|
150
|
+
_run_pipeline, req.prompt, req.width, req.height, req.num_inference_steps, req.seed
|
|
151
|
+
)
|
|
152
|
+
logger.info("text2img complete")
|
|
153
|
+
return StreamingResponse(buf, media_type="image/png")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.post("/img2img", dependencies=[Depends(verify_api_key)])
|
|
157
|
+
async def img2img(
|
|
158
|
+
image: UploadFile = File(...),
|
|
159
|
+
prompt: str = Form(..., min_length=1, max_length=2000),
|
|
160
|
+
strength: float = Form(default=0.75, ge=0.0, le=1.0),
|
|
161
|
+
num_inference_steps: int = Form(default=25, ge=1, le=50),
|
|
162
|
+
seed: int | None = Form(default=None),
|
|
163
|
+
):
|
|
164
|
+
if img2img_pipe is None:
|
|
165
|
+
raise HTTPException(status_code=503, detail="Model is still loading")
|
|
166
|
+
|
|
167
|
+
image_bytes = await image.read()
|
|
168
|
+
try:
|
|
169
|
+
pil_image = Image.open(BytesIO(image_bytes)).convert("RGB")
|
|
170
|
+
except Exception:
|
|
171
|
+
raise HTTPException(status_code=400, detail="Invalid image file")
|
|
172
|
+
|
|
173
|
+
logger.info("img2img (strength=%.2f): %s", strength, prompt[:80])
|
|
174
|
+
buf = await asyncio.to_thread(
|
|
175
|
+
_run_img2img_pipeline, pil_image, prompt, strength, num_inference_steps, seed,
|
|
176
|
+
)
|
|
177
|
+
logger.info("img2img complete")
|
|
178
|
+
return StreamingResponse(buf, media_type="image/png")
|
infra/chat/app/main.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import httpx
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
6
|
+
from fastapi.templating import Jinja2Templates
|
|
7
|
+
|
|
8
|
+
app = FastAPI(title="Nyx Chat")
|
|
9
|
+
templates = Jinja2Templates(directory="templates")
|
|
10
|
+
|
|
11
|
+
VLLM_BASE_URL = os.getenv("VLLM_BASE_URL", "http://localhost:8000/v1")
|
|
12
|
+
|
|
13
|
+
SYSTEM_PROMPT = {
|
|
14
|
+
"role": "system",
|
|
15
|
+
"content": (
|
|
16
|
+
"You are Kairo, an AI assistant. "
|
|
17
|
+
"You are powered by the Nyx model. "
|
|
18
|
+
"The Kairo model family includes Nyx (lightweight), Theron (code specialist), and Helios (flagship). "
|
|
19
|
+
"You are NOT GPT, ChatGPT, Claude, Llama, or any other third-party AI. You are Kairo. "
|
|
20
|
+
"Never reveal your underlying architecture or training details. "
|
|
21
|
+
"You are helpful, concise, and knowledgeable."
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.get("/", response_class=HTMLResponse)
|
|
27
|
+
async def index(request: Request):
|
|
28
|
+
return templates.TemplateResponse("index.html", {"request": request})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.get("/api/health")
|
|
32
|
+
async def health():
|
|
33
|
+
try:
|
|
34
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
35
|
+
r = await client.get(f"{VLLM_BASE_URL}/models")
|
|
36
|
+
models = r.json()
|
|
37
|
+
return {"status": "ok", "models": models}
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return {"status": "error", "detail": str(e)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.post("/api/chat")
|
|
43
|
+
async def chat(request: Request):
|
|
44
|
+
body = await request.json()
|
|
45
|
+
messages = body.get("messages", [])
|
|
46
|
+
model = body.get("model", "")
|
|
47
|
+
temperature = body.get("temperature", 0.7)
|
|
48
|
+
max_tokens = body.get("max_tokens", 2048)
|
|
49
|
+
|
|
50
|
+
# Prepend system prompt if not already present
|
|
51
|
+
if not messages or messages[0].get("role") != "system":
|
|
52
|
+
messages = [SYSTEM_PROMPT] + messages
|
|
53
|
+
|
|
54
|
+
payload = {
|
|
55
|
+
"model": model,
|
|
56
|
+
"messages": messages,
|
|
57
|
+
"temperature": temperature,
|
|
58
|
+
"max_tokens": max_tokens,
|
|
59
|
+
"stream": True,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async def stream_response():
|
|
63
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
|
|
64
|
+
async with client.stream(
|
|
65
|
+
"POST",
|
|
66
|
+
f"{VLLM_BASE_URL}/chat/completions",
|
|
67
|
+
json=payload,
|
|
68
|
+
) as response:
|
|
69
|
+
async for line in response.aiter_lines():
|
|
70
|
+
if line.startswith("data: "):
|
|
71
|
+
data = line[6:]
|
|
72
|
+
if data.strip() == "[DONE]":
|
|
73
|
+
yield "data: [DONE]\n\n"
|
|
74
|
+
break
|
|
75
|
+
try:
|
|
76
|
+
chunk = json.loads(data)
|
|
77
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
78
|
+
content = delta.get("content", "")
|
|
79
|
+
if content:
|
|
80
|
+
yield f"data: {json.dumps({'content': content})}\n\n"
|
|
81
|
+
except (json.JSONDecodeError, KeyError, IndexError):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
return StreamingResponse(stream_response(), media_type="text/event-stream")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
|
|
3
|
+
from backend.core.admin_auth import require_role
|
|
4
|
+
|
|
5
|
+
from backend.api.admin.users import router as users_router
|
|
6
|
+
from backend.api.admin.stats import router as stats_router
|
|
7
|
+
from backend.api.admin.system import router as system_router
|
|
8
|
+
from backend.api.admin.content import router as content_router
|
|
9
|
+
from backend.api.admin.audit import router as audit_router
|
|
10
|
+
from backend.api.admin.incidents import router as incidents_router
|
|
11
|
+
|
|
12
|
+
router = APIRouter(
|
|
13
|
+
prefix="/admin",
|
|
14
|
+
tags=["admin"],
|
|
15
|
+
dependencies=[Depends(require_role("admin"))],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
router.include_router(users_router)
|
|
19
|
+
router.include_router(stats_router)
|
|
20
|
+
router.include_router(system_router)
|
|
21
|
+
router.include_router(content_router)
|
|
22
|
+
router.include_router(audit_router)
|
|
23
|
+
router.include_router(incidents_router)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Query
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from backend.core.admin_auth import require_role
|
|
8
|
+
from backend.core.database import get_db
|
|
9
|
+
from backend.schemas.admin.audit import AuditLogEntry
|
|
10
|
+
from backend.services.admin.audit_service import AuditService
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
router = APIRouter(
|
|
15
|
+
prefix="/audit-logs",
|
|
16
|
+
tags=["admin-audit"],
|
|
17
|
+
dependencies=[Depends(require_role("superadmin"))],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("")
|
|
22
|
+
async def list_audit_logs(
|
|
23
|
+
admin_id: str | None = Query(None),
|
|
24
|
+
action: str | None = Query(None),
|
|
25
|
+
target_type: str | None = Query(None),
|
|
26
|
+
result: str | None = Query(None),
|
|
27
|
+
date_from: datetime | None = Query(None),
|
|
28
|
+
date_to: datetime | None = Query(None),
|
|
29
|
+
cursor: str | None = Query(None),
|
|
30
|
+
limit: int = Query(50, ge=1, le=100),
|
|
31
|
+
db: AsyncSession = Depends(get_db),
|
|
32
|
+
):
|
|
33
|
+
filters = {}
|
|
34
|
+
if admin_id:
|
|
35
|
+
filters["admin_id"] = admin_id
|
|
36
|
+
if action:
|
|
37
|
+
filters["action"] = action
|
|
38
|
+
if target_type:
|
|
39
|
+
filters["target_type"] = target_type
|
|
40
|
+
if result:
|
|
41
|
+
filters["result"] = result
|
|
42
|
+
if date_from:
|
|
43
|
+
filters["date_from"] = date_from
|
|
44
|
+
if date_to:
|
|
45
|
+
filters["date_to"] = date_to
|
|
46
|
+
|
|
47
|
+
svc = AuditService(db)
|
|
48
|
+
logs = await svc.list_logs(filters=filters or None, cursor=cursor, limit=limit + 1)
|
|
49
|
+
has_more = len(logs) > limit
|
|
50
|
+
if has_more:
|
|
51
|
+
logs = logs[:limit]
|
|
52
|
+
items = [AuditLogEntry.model_validate(log) for log in logs]
|
|
53
|
+
next_cursor = logs[-1].id if has_more and logs else None
|
|
54
|
+
return {"items": items, "next_cursor": next_cursor}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from backend.core.admin_auth import require_role
|
|
7
|
+
from backend.core.database import get_db
|
|
8
|
+
from backend.models.user import User
|
|
9
|
+
from backend.schemas.admin.content import (
|
|
10
|
+
AdminConversationDetail,
|
|
11
|
+
AdminConversationListItem,
|
|
12
|
+
AdminImageListItem,
|
|
13
|
+
ContentDeleteRequest,
|
|
14
|
+
)
|
|
15
|
+
from backend.services.admin.audit_service import AuditService
|
|
16
|
+
from backend.services.admin.content_service import AdminContentService
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(
|
|
21
|
+
tags=["admin-content"],
|
|
22
|
+
dependencies=[Depends(require_role("moderator"))],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_ip(request: Request) -> str:
|
|
27
|
+
return request.client.host if request.client else "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_ua(request: Request) -> str:
|
|
31
|
+
return request.headers.get("user-agent", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/conversations")
|
|
35
|
+
async def list_conversations(
|
|
36
|
+
search: str | None = Query(None),
|
|
37
|
+
user_id: str | None = Query(None),
|
|
38
|
+
cursor: str | None = Query(None),
|
|
39
|
+
limit: int = Query(25, ge=1, le=100),
|
|
40
|
+
db: AsyncSession = Depends(get_db),
|
|
41
|
+
):
|
|
42
|
+
svc = AdminContentService(db)
|
|
43
|
+
data = await svc.list_conversations(search=search, user_id=user_id, cursor=cursor, limit=limit + 1)
|
|
44
|
+
has_more = len(data) > limit
|
|
45
|
+
if has_more:
|
|
46
|
+
data = data[:limit]
|
|
47
|
+
items = [AdminConversationListItem(**item) for item in data]
|
|
48
|
+
next_cursor = data[-1]["id"] if has_more and data else None
|
|
49
|
+
return {"items": items, "next_cursor": next_cursor}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/conversations/{conversation_id}", response_model=AdminConversationDetail)
|
|
53
|
+
async def get_conversation_detail(
|
|
54
|
+
conversation_id: str,
|
|
55
|
+
request: Request,
|
|
56
|
+
db: AsyncSession = Depends(get_db),
|
|
57
|
+
admin: User = Depends(require_role("moderator")),
|
|
58
|
+
):
|
|
59
|
+
svc = AdminContentService(db)
|
|
60
|
+
data = await svc.get_conversation_detail(conversation_id)
|
|
61
|
+
if not data:
|
|
62
|
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
63
|
+
|
|
64
|
+
audit = AuditService(db)
|
|
65
|
+
await audit.log(
|
|
66
|
+
admin_user_id=admin.id,
|
|
67
|
+
action="content.conversation_viewed",
|
|
68
|
+
target_type="conversation",
|
|
69
|
+
target_id=conversation_id,
|
|
70
|
+
ip_address=_get_ip(request),
|
|
71
|
+
user_agent=_get_ua(request),
|
|
72
|
+
)
|
|
73
|
+
return AdminConversationDetail(**data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.delete("/conversations/{conversation_id}")
|
|
77
|
+
async def delete_conversation(
|
|
78
|
+
conversation_id: str,
|
|
79
|
+
body: ContentDeleteRequest,
|
|
80
|
+
request: Request,
|
|
81
|
+
db: AsyncSession = Depends(get_db),
|
|
82
|
+
admin: User = Depends(require_role("moderator")),
|
|
83
|
+
):
|
|
84
|
+
svc = AdminContentService(db)
|
|
85
|
+
deleted = await svc.delete_conversation(conversation_id)
|
|
86
|
+
if not deleted:
|
|
87
|
+
raise HTTPException(status_code=404, detail="Conversation not found")
|
|
88
|
+
|
|
89
|
+
audit = AuditService(db)
|
|
90
|
+
await audit.log(
|
|
91
|
+
admin_user_id=admin.id,
|
|
92
|
+
action="content.conversation_deleted",
|
|
93
|
+
target_type="conversation",
|
|
94
|
+
target_id=conversation_id,
|
|
95
|
+
details={"reason": body.reason},
|
|
96
|
+
ip_address=_get_ip(request),
|
|
97
|
+
user_agent=_get_ua(request),
|
|
98
|
+
)
|
|
99
|
+
return {"message": "Conversation deleted"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/images/recent")
|
|
103
|
+
async def list_recent_images(
|
|
104
|
+
user_id: str | None = Query(None),
|
|
105
|
+
cursor: str | None = Query(None),
|
|
106
|
+
limit: int = Query(50, ge=1, le=100),
|
|
107
|
+
db: AsyncSession = Depends(get_db),
|
|
108
|
+
):
|
|
109
|
+
svc = AdminContentService(db)
|
|
110
|
+
images = await svc.list_recent_images(user_id=user_id, cursor=cursor, limit=limit + 1)
|
|
111
|
+
has_more = len(images) > limit
|
|
112
|
+
if has_more:
|
|
113
|
+
images = images[:limit]
|
|
114
|
+
items = [AdminImageListItem.model_validate(img) for img in images]
|
|
115
|
+
next_cursor = images[-1].id if has_more and images else None
|
|
116
|
+
return {"items": items, "next_cursor": next_cursor}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.delete("/images/{image_id}")
|
|
120
|
+
async def delete_image(
|
|
121
|
+
image_id: str,
|
|
122
|
+
body: ContentDeleteRequest,
|
|
123
|
+
request: Request,
|
|
124
|
+
db: AsyncSession = Depends(get_db),
|
|
125
|
+
admin: User = Depends(require_role("moderator")),
|
|
126
|
+
):
|
|
127
|
+
svc = AdminContentService(db)
|
|
128
|
+
deleted = await svc.delete_image(image_id)
|
|
129
|
+
if not deleted:
|
|
130
|
+
raise HTTPException(status_code=404, detail="Image not found")
|
|
131
|
+
|
|
132
|
+
audit = AuditService(db)
|
|
133
|
+
await audit.log(
|
|
134
|
+
admin_user_id=admin.id,
|
|
135
|
+
action="content.image_deleted",
|
|
136
|
+
target_type="image",
|
|
137
|
+
target_id=image_id,
|
|
138
|
+
details={"reason": body.reason},
|
|
139
|
+
ip_address=_get_ip(request),
|
|
140
|
+
user_agent=_get_ua(request),
|
|
141
|
+
)
|
|
142
|
+
return {"message": "Image deleted"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime, UTC
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from backend.core.admin_auth import require_role
|
|
8
|
+
from backend.core.database import get_db
|
|
9
|
+
from backend.models.user import User
|
|
10
|
+
from backend.schemas.status import (
|
|
11
|
+
CreateIncidentRequest,
|
|
12
|
+
IncidentSummary,
|
|
13
|
+
UpdateIncidentRequest,
|
|
14
|
+
)
|
|
15
|
+
from backend.services.admin.audit_service import AuditService
|
|
16
|
+
from backend.services.admin.incident_service import IncidentService
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/incidents", tags=["admin-incidents"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _incident_to_summary(incident) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"id": incident.id,
|
|
26
|
+
"title": incident.title,
|
|
27
|
+
"description": incident.description,
|
|
28
|
+
"severity": incident.severity,
|
|
29
|
+
"component": incident.component,
|
|
30
|
+
"status": incident.status,
|
|
31
|
+
"started_at": incident.started_at.isoformat(),
|
|
32
|
+
"resolved_at": incident.resolved_at.isoformat() if incident.resolved_at else None,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("", response_model=IncidentSummary)
|
|
37
|
+
async def create_incident(
|
|
38
|
+
body: CreateIncidentRequest,
|
|
39
|
+
request: Request,
|
|
40
|
+
db: AsyncSession = Depends(get_db),
|
|
41
|
+
admin: User = Depends(require_role("admin")),
|
|
42
|
+
):
|
|
43
|
+
"""Create a new incident."""
|
|
44
|
+
svc = IncidentService(db)
|
|
45
|
+
incident = await svc.create_incident(
|
|
46
|
+
title=body.title,
|
|
47
|
+
description=body.description,
|
|
48
|
+
severity=body.severity,
|
|
49
|
+
component=body.component,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Audit log
|
|
53
|
+
audit = AuditService(db)
|
|
54
|
+
ip = request.client.host if request.client else "unknown"
|
|
55
|
+
ua = request.headers.get("user-agent", "")
|
|
56
|
+
await audit.log(
|
|
57
|
+
admin_user_id=admin.id,
|
|
58
|
+
action="incident.created",
|
|
59
|
+
target_type="incident",
|
|
60
|
+
target_id=incident.id,
|
|
61
|
+
details={"title": body.title, "component": body.component, "severity": body.severity},
|
|
62
|
+
ip_address=ip,
|
|
63
|
+
user_agent=ua,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return IncidentSummary(**_incident_to_summary(incident))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("", response_model=list[IncidentSummary])
|
|
70
|
+
async def list_incidents(
|
|
71
|
+
limit: int = Query(default=50, le=200),
|
|
72
|
+
include_resolved: bool = Query(default=False),
|
|
73
|
+
db: AsyncSession = Depends(get_db),
|
|
74
|
+
):
|
|
75
|
+
"""List incidents. Defaults to active (unresolved) incidents only."""
|
|
76
|
+
svc = IncidentService(db)
|
|
77
|
+
incidents = await svc.list_incidents(limit=limit, include_resolved=include_resolved)
|
|
78
|
+
return [IncidentSummary(**_incident_to_summary(i)) for i in incidents]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.patch("/{incident_id}", response_model=IncidentSummary)
|
|
82
|
+
async def update_incident(
|
|
83
|
+
incident_id: str,
|
|
84
|
+
body: UpdateIncidentRequest,
|
|
85
|
+
request: Request,
|
|
86
|
+
db: AsyncSession = Depends(get_db),
|
|
87
|
+
admin: User = Depends(require_role("admin")),
|
|
88
|
+
):
|
|
89
|
+
"""Update an incident's fields."""
|
|
90
|
+
svc = IncidentService(db)
|
|
91
|
+
|
|
92
|
+
update_data = body.model_dump(exclude_unset=True)
|
|
93
|
+
# Parse resolved_at if provided as ISO string
|
|
94
|
+
if "resolved_at" in update_data and update_data["resolved_at"] is not None:
|
|
95
|
+
try:
|
|
96
|
+
update_data["resolved_at"] = datetime.fromisoformat(update_data["resolved_at"])
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise HTTPException(status_code=400, detail="Invalid resolved_at format. Use ISO 8601.")
|
|
99
|
+
|
|
100
|
+
incident = await svc.update_incident(incident_id, **update_data)
|
|
101
|
+
if not incident:
|
|
102
|
+
raise HTTPException(status_code=404, detail="Incident not found")
|
|
103
|
+
|
|
104
|
+
# Audit log
|
|
105
|
+
audit = AuditService(db)
|
|
106
|
+
ip = request.client.host if request.client else "unknown"
|
|
107
|
+
ua = request.headers.get("user-agent", "")
|
|
108
|
+
await audit.log(
|
|
109
|
+
admin_user_id=admin.id,
|
|
110
|
+
action="incident.updated",
|
|
111
|
+
target_type="incident",
|
|
112
|
+
target_id=incident_id,
|
|
113
|
+
details=update_data,
|
|
114
|
+
ip_address=ip,
|
|
115
|
+
user_agent=ua,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return IncidentSummary(**_incident_to_summary(incident))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/{incident_id}/resolve", response_model=IncidentSummary)
|
|
122
|
+
async def resolve_incident(
|
|
123
|
+
incident_id: str,
|
|
124
|
+
request: Request,
|
|
125
|
+
db: AsyncSession = Depends(get_db),
|
|
126
|
+
admin: User = Depends(require_role("admin")),
|
|
127
|
+
):
|
|
128
|
+
"""Mark an incident as resolved."""
|
|
129
|
+
svc = IncidentService(db)
|
|
130
|
+
incident = await svc.resolve_incident(incident_id)
|
|
131
|
+
if not incident:
|
|
132
|
+
raise HTTPException(status_code=404, detail="Incident not found")
|
|
133
|
+
|
|
134
|
+
# Audit log
|
|
135
|
+
audit = AuditService(db)
|
|
136
|
+
ip = request.client.host if request.client else "unknown"
|
|
137
|
+
ua = request.headers.get("user-agent", "")
|
|
138
|
+
await audit.log(
|
|
139
|
+
admin_user_id=admin.id,
|
|
140
|
+
action="incident.resolved",
|
|
141
|
+
target_type="incident",
|
|
142
|
+
target_id=incident_id,
|
|
143
|
+
details={},
|
|
144
|
+
ip_address=ip,
|
|
145
|
+
user_agent=ua,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return IncidentSummary(**_incident_to_summary(incident))
|