Files
ticket/web/api/app.py
2026-07-16 12:27:06 +02:00

1055 lines
34 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Literal
import base64
import hashlib
import hmac
import os
import secrets
import sys
import psycopg2
from psycopg2.extras import DictCursor
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
ROOT_DIR = Path(__file__).resolve().parents[2]
API_DIR = ROOT_DIR / "api"
CONN_DIR = ROOT_DIR / "conn"
for path in (API_DIR, CONN_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import ai_retriever
import config
import mysql5
from bootstrap_db import ensure_bot_schema
from ai_index import ensure_ai_schema
COOKIE_NAME = "ticket_web_session"
SESSION_DAYS = int(os.getenv("TICKET_WEB_SESSION_DAYS", "7"))
STATIC_DIR = Path(os.getenv("TICKET_WEB_STATIC_DIR", ROOT_DIR / "web" / "static")).resolve()
WEB_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS ticket_web_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'admin',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
CHECK (role IN ('admin'))
);
CREATE TABLE IF NOT EXISTS ticket_web_sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES ticket_web_users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT now(),
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS ticket_web_sessions_user_idx ON ticket_web_sessions (user_id);
CREATE INDEX IF NOT EXISTS ticket_web_sessions_expires_idx ON ticket_web_sessions (expires_at);
"""
BOT_COMPATIBILITY_SQL = """
ALTER TABLE users ADD COLUMN IF NOT EXISTS user_admin BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now();
ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT now();
ALTER TABLE filters ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now();
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now();
"""
FILTER_TYPES = {
"A": {"name": "Area", "table": "aree", "label_column": "AREA"},
"S": {"name": "Sottoarea", "table": "sottoaree", "label_column": "SOTTOAREA"},
"C": {"name": "Competenza", "table": "competenze", "label_column": "COMPETENZA"},
"P": {"name": "Prodotto", "table": "prodotti", "label_column": "PRODOTTO"},
}
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=1, max_length=256)
class UserPatch(BaseModel):
status: Literal["P", "A"] | None = None
user_admin: bool | None = None
class FilterCreate(BaseModel):
sort: Literal["A", "S", "C", "P"]
sort_value: str = Field(min_length=1, max_length=80)
class SettingsPatch(BaseModel):
notifications_enabled: bool | None = None
quiet_start_hour: int | None = Field(default=None, ge=0, le=23)
quiet_end_hour: int | None = Field(default=None, ge=0, le=23)
digest_minutes: int | None = Field(default=None, ge=0, le=1440)
high_gravity_only: bool | None = None
class FeedbackCreate(BaseModel):
suggested_ticket_id: int
query_ticket_id: int | None = None
feedback: Literal["useful", "not_useful", "favorite", "opened"]
note: str | None = Field(default=None, max_length=500)
app = FastAPI(
title="Ticket Web Console",
version="1.0.0",
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None or value == "":
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _pg_connection():
return psycopg2.connect(**config.POSTGRES_CONFIG)
def _serialize(value: Any) -> Any:
if isinstance(value, dict):
return {key: _serialize(item) for key, item in value.items()}
if isinstance(value, list):
return [_serialize(item) for item in value]
if isinstance(value, tuple):
return [_serialize(item) for item in value]
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
return value
def _hash_password(password: str, salt: bytes | None = None, iterations: int = 390000) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
salt_text = base64.urlsafe_b64encode(salt).decode("ascii")
digest_text = base64.urlsafe_b64encode(digest).decode("ascii")
return f"pbkdf2_sha256${iterations}${salt_text}${digest_text}"
def _verify_password(password: str, stored_hash: str) -> bool:
try:
scheme, iterations_text, salt_text, digest_text = stored_hash.split("$", 3)
if scheme != "pbkdf2_sha256":
return False
iterations = int(iterations_text)
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _web_actor_id(web_user: dict[str, Any]) -> int:
return -int(web_user["id"])
def _public_web_user(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"username": row["username"],
"display_name": row.get("display_name") or row["username"],
"role": row["role"],
}
def _model_fields_set(model: BaseModel) -> set[str]:
return getattr(model, "model_fields_set", getattr(model, "__fields_set__", set()))
def ensure_web_schema() -> None:
ensure_bot_schema()
ensure_ai_schema()
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute(BOT_COMPATIBILITY_SQL)
cursor.execute(WEB_SCHEMA_SQL)
cursor.execute("SELECT COUNT(*) AS count FROM ticket_web_users")
count = int(cursor.fetchone()["count"])
if count == 0:
username = os.getenv("TICKET_WEB_ADMIN_USERNAME", "admin")
password = os.getenv("TICKET_WEB_ADMIN_PASSWORD", "change-me-now")
display_name = os.getenv("TICKET_WEB_ADMIN_DISPLAY_NAME", "Web administrator")
cursor.execute(
"""
INSERT INTO ticket_web_users (username, password_hash, display_name, role)
VALUES (%s, %s, %s, 'admin')
""",
(username, _hash_password(password), display_name),
)
cursor.execute("DELETE FROM ticket_web_sessions WHERE expires_at < now()")
connection.commit()
cursor.close()
connection.close()
@app.on_event("startup")
def _startup() -> None:
ensure_web_schema()
def current_web_user(request: Request) -> dict[str, Any]:
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
web_user = _find_web_user_by_token(token)
if not web_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return web_user
def optional_web_user(request: Request) -> dict[str, Any] | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
return _find_web_user_by_token(token)
def _find_web_user_by_token(token: str) -> dict[str, Any] | None:
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute(
"""
SELECT u.id, u.username, u.display_name, u.role
FROM ticket_web_sessions s
JOIN ticket_web_users u ON u.id = s.user_id
WHERE s.token_hash = %s
AND s.expires_at > now()
""",
(_hash_token(token),),
)
row = cursor.fetchone()
cursor.close()
connection.close()
return dict(row) if row else None
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
secure=_env_bool("TICKET_WEB_COOKIE_SECURE", False),
samesite="lax",
)
def _clear_session_cookie(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, httponly=True, samesite="lax")
def _assert_select(sql: str) -> None:
if not sql.lstrip().upper().startswith("SELECT"):
raise ValueError("MySQL web queries must be read-only SELECT statements")
def _mysql_fetch_all(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
_assert_select(sql)
connection = mysql5.getMySqlConnection()
try:
try:
connection.start_transaction(readonly=True)
except Exception:
pass
cursor = connection.cursor(dictionary=True)
cursor.execute(sql, params)
rows = cursor.fetchall()
cursor.close()
try:
connection.rollback()
except Exception:
pass
return _serialize(rows)
finally:
connection.close()
def _mysql_fetch_one(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
rows = _mysql_fetch_all(sql, params)
return rows[0] if rows else None
def _pg_fetch_all(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute(sql, params)
rows = [dict(row) for row in cursor.fetchall()]
cursor.close()
connection.close()
return _serialize(rows)
def _pg_fetch_one(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
rows = _pg_fetch_all(sql, params)
return rows[0] if rows else None
def _ticket_link(ticket_id: int | str) -> str:
return f"https://tsnew.sanmarcoweb.com/it/ticket/index/index/operation/view/id/{ticket_id}"
def _normalize_filter_value(value: str) -> str:
value = str(value).strip()
if value.startswith("_"):
return "-" + value[1:]
return value
def _as_int(value: Any) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _filter_label(sort: str, sort_value: str) -> str | None:
metadata = FILTER_TYPES.get(sort)
value = _as_int(sort_value)
if not metadata or value is None:
return None
sql = f"""
SELECT {metadata["label_column"]} AS label
FROM {metadata["table"]}
WHERE ID = %s
LIMIT 1
"""
row = _mysql_fetch_one(sql, (value,))
return str(row["label"]) if row and row.get("label") is not None else None
def _decorate_filter(row: dict[str, Any]) -> dict[str, Any]:
sort = str(row.get("sort") or "")
value = str(row.get("sort_value") or "")
row["sort_name"] = FILTER_TYPES.get(sort, {}).get("name", sort)
try:
row["label"] = _filter_label(sort, value)
except Exception:
row["label"] = None
return row
def _search_filters_from_params(
product_id: int | None,
area_id: int | None,
subarea_id: int | None,
competence_id: int | None,
client_id: int | None,
recent_months: int | None,
min_confidence: str | None,
require_code_match: bool,
) -> dict[str, Any]:
filters: dict[str, Any] = {}
if product_id is not None:
filters["product_ids"] = [product_id]
if area_id is not None:
filters["area_ids"] = [area_id]
if subarea_id is not None:
filters["subarea_ids"] = [subarea_id]
if competence_id is not None:
filters["competence_ids"] = [competence_id]
if client_id is not None:
filters["client_ids"] = [client_id]
if recent_months is not None and recent_months > 0:
filters["recent_months"] = recent_months
if min_confidence:
filters["min_confidence"] = min_confidence
if require_code_match:
filters["require_code_match"] = True
return filters
@app.get("/api/health")
def health() -> dict[str, Any]:
return {"ok": True, "time": datetime.utcnow().isoformat()}
@app.post("/api/auth/login")
def login(payload: LoginRequest, response: Response) -> dict[str, Any]:
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute(
"""
SELECT id, username, password_hash, display_name, role
FROM ticket_web_users
WHERE username = %s
""",
(payload.username,),
)
row = cursor.fetchone()
if not row or not _verify_password(payload.password, row["password_hash"]):
cursor.close()
connection.close()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
token = secrets.token_urlsafe(48)
expires_at = datetime.utcnow() + timedelta(days=SESSION_DAYS)
cursor.execute(
"""
INSERT INTO ticket_web_sessions (token_hash, user_id, expires_at)
VALUES (%s, %s, %s)
""",
(_hash_token(token), row["id"], expires_at),
)
connection.commit()
cursor.close()
connection.close()
_set_session_cookie(response, token)
return {"user": _public_web_user(dict(row))}
@app.get("/api/auth/me")
def me(web_user: dict[str, Any] | None = Depends(optional_web_user)) -> dict[str, Any]:
return {"user": _public_web_user(web_user) if web_user else None}
@app.post("/api/auth/logout")
def logout(
request: Request,
response: Response,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
token = request.cookies.get(COOKIE_NAME)
if token:
connection = _pg_connection()
cursor = connection.cursor()
cursor.execute("DELETE FROM ticket_web_sessions WHERE token_hash = %s", (_hash_token(token),))
connection.commit()
cursor.close()
connection.close()
_clear_session_cookie(response)
return {"ok": True, "user_id": web_user["id"]}
@app.get("/api/dashboard")
def dashboard(web_user: dict[str, Any] = Depends(current_web_user)) -> dict[str, Any]:
stats = _serialize(ai_retriever.get_ai_stats())
bot_counts = _pg_fetch_one(
"""
SELECT
COUNT(*) AS users,
COALESCE(SUM(CASE WHEN status = 'P' THEN 1 ELSE 0 END), 0) AS pending_users,
COALESCE(SUM(CASE WHEN status = 'A' THEN 1 ELSE 0 END), 0) AS approved_users
FROM users
"""
) or {}
filter_count = _pg_fetch_one("SELECT COUNT(*) AS filters FROM filters") or {"filters": 0}
settings_count = _pg_fetch_one(
"""
SELECT
COUNT(*) AS configured_users,
COALESCE(SUM(CASE WHEN notifications_enabled THEN 0 ELSE 1 END), 0) AS paused_notifications,
COALESCE(SUM(CASE WHEN high_gravity_only THEN 1 ELSE 0 END), 0) AS high_gravity_users
FROM ticket_bot_user_settings
"""
) or {}
recent_feedback = _pg_fetch_all(
"""
SELECT
f.created_at,
f.user_id,
f.query_ticket_id,
f.suggested_ticket_id,
f.feedback,
i.subject_text,
i.product,
i.area
FROM ticket_ai_feedback f
LEFT JOIN ticket_ai_index i ON i.ticket_id = f.suggested_ticket_id
ORDER BY f.created_at DESC
LIMIT 8
"""
)
mysql_status: dict[str, Any] = {"ok": True, "error": None}
recent_tickets: list[dict[str, Any]] = []
try:
recent_tickets = _mysql_fetch_all(
"""
SELECT
s.TICKET AS ticket_id,
cl.INTESTATARIO AS client,
s.OGGETTO AS subject,
g.GRAVITA AS gravity,
c.COMPETENZA AS competence,
p.PRODOTTO AS product,
st.STATO AS status,
s.APERTURA AS opened_at
FROM segnalazioni s
LEFT JOIN clienti cl ON cl.ID = s.CLIENTE
LEFT JOIN gravita g ON g.ID = s.GRAVITA
LEFT JOIN competenze c ON c.ID = s.COMPETENZA
LEFT JOIN prodotti p ON p.ID = s.PRODOTTO
LEFT JOIN stati st ON st.ID = s.STATO
WHERE s.APERTURA >= %s
ORDER BY s.APERTURA DESC
LIMIT 8
""",
(date.today() - timedelta(days=14),),
)
except Exception as exc:
mysql_status = {"ok": False, "error": str(exc)}
return {
"ai": stats,
"bot": {**bot_counts, **filter_count, **settings_count},
"recent_feedback": recent_feedback,
"recent_tickets": recent_tickets,
"mysql": mysql_status,
"viewer": _public_web_user(web_user),
}
@app.get("/api/tickets")
def list_tickets(
q: str | None = Query(default=None, max_length=120),
date_from: date | None = None,
date_to: date | None = None,
product_id: int | None = None,
competence_id: int | None = None,
area_id: int | None = None,
subarea_id: int | None = None,
status_id: int | None = None,
client_id: int | None = None,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
where_parts: list[str] = []
params: list[Any] = []
protected_range_applied = False
query = (q or "").strip()
if query:
if query.isdigit():
where_parts.append("s.TICKET = %s")
params.append(int(query))
else:
like = f"%{query[:80]}%"
where_parts.append("(s.OGGETTO LIKE %s OR cl.INTESTATARIO LIKE %s)")
params.extend([like, like])
elif date_from is None and date_to is None:
date_from = date.today() - timedelta(days=30)
protected_range_applied = True
if date_from is not None:
where_parts.append("s.APERTURA >= %s")
params.append(date_from)
if date_to is not None:
where_parts.append("s.APERTURA < %s")
params.append(date_to + timedelta(days=1))
filter_columns = {
"s.PRODOTTO": product_id,
"s.COMPETENZA": competence_id,
"s.AREA": area_id,
"s.SOTTOAREA": subarea_id,
"s.STATO": status_id,
"s.CLIENTE": client_id,
}
for column, value in filter_columns.items():
if value is not None:
where_parts.append(f"{column} = %s")
params.append(value)
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
page_size = int(limit) + 1
rows = _mysql_fetch_all(
f"""
SELECT
s.TICKET AS ticket_id,
cl.INTESTATARIO AS client,
s.OGGETTO AS subject,
g.GRAVITA AS gravity,
c.COMPETENZA AS competence,
p.PRODOTTO AS product,
a.AREA AS area,
sa.SOTTOAREA AS subarea,
st.STATO AS status,
s.APERTURA AS opened_at,
s.CHIUSURA AS closed_at
FROM segnalazioni s
LEFT JOIN clienti cl ON cl.ID = s.CLIENTE
LEFT JOIN gravita g ON g.ID = s.GRAVITA
LEFT JOIN competenze c ON c.ID = s.COMPETENZA
LEFT JOIN prodotti p ON p.ID = s.PRODOTTO
LEFT JOIN aree a ON a.ID = s.AREA
LEFT JOIN sottoaree sa ON sa.ID = s.SOTTOAREA
LEFT JOIN stati st ON st.ID = s.STATO
WHERE {where_sql}
ORDER BY s.APERTURA DESC
LIMIT %s OFFSET %s
""",
tuple(params + [page_size, offset]),
)
items = rows[:limit]
for item in items:
item["source_link"] = _ticket_link(item["ticket_id"])
return {
"items": items,
"has_more": len(rows) > limit,
"limit": limit,
"offset": offset,
"protected_range_applied": protected_range_applied,
"viewer_id": web_user["id"],
}
@app.get("/api/tickets/{ticket_id}")
def ticket_detail(
ticket_id: int,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
ticket = _mysql_fetch_one(
"""
SELECT
s.TICKET AS ticket_id,
s.OGGETTO AS subject,
s.PROBLEMA AS problem,
s.SOLUZIONE AS solution,
s.COMPETENZA AS competence_id,
c.COMPETENZA AS competence,
s.PRODOTTO AS product_id,
p.PRODOTTO AS product,
s.AREA AS area_id,
a.AREA AS area,
s.SOTTOAREA AS subarea_id,
sa.SOTTOAREA AS subarea,
s.CLIENTE AS client_id,
cl.INTESTATARIO AS client,
s.GRAVITA AS gravity_id,
g.GRAVITA AS gravity,
s.STATO AS status_id,
st.STATO AS status,
s.APERTURA AS opened_at,
s.CHIUSURA AS closed_at,
s.PROGRAMMA AS program,
s.TIPO_PROGRAMMA AS program_type,
s.VERSIONE AS version_id,
s.PATCH AS patch_id,
s.CLASSIFICAZIONE AS classification_id,
s.SISTEMA_OPERATIVO AS os_id
FROM segnalazioni s
LEFT JOIN competenze c ON c.ID = s.COMPETENZA
LEFT JOIN prodotti p ON p.ID = s.PRODOTTO
LEFT JOIN aree a ON a.ID = s.AREA
LEFT JOIN sottoaree sa ON sa.ID = s.SOTTOAREA
LEFT JOIN clienti cl ON cl.ID = s.CLIENTE
LEFT JOIN gravita g ON g.ID = s.GRAVITA
LEFT JOIN stati st ON st.ID = s.STATO
WHERE s.TICKET = %s
LIMIT 1
""",
(ticket_id,),
)
if not ticket:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket not found")
data = _serialize(ticket)
data["source_link"] = _ticket_link(ticket_id)
return {"ticket": data, "viewer_id": web_user["id"]}
@app.get("/api/search/text")
def search_text(
q: str = Query(min_length=2, max_length=1000),
product_id: int | None = None,
area_id: int | None = None,
subarea_id: int | None = None,
competence_id: int | None = None,
client_id: int | None = None,
recent_months: int | None = Query(default=None, ge=1, le=120),
min_confidence: Literal["media", "alta"] | None = None,
require_code_match: bool = False,
limit: int = Query(default=8, ge=1, le=20),
offset: int = Query(default=0, ge=0),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
filters = _search_filters_from_params(
product_id,
area_id,
subarea_id,
competence_id,
client_id,
recent_months,
min_confidence,
require_code_match,
)
rows = ai_retriever.search_similar_text(
q,
user_id=_web_actor_id(web_user),
limit=limit,
offset=offset,
filters=filters,
)
return {"items": _serialize(rows), "filters": filters, "limit": limit, "offset": offset}
@app.get("/api/search/ticket/{ticket_id}")
def search_ticket(
ticket_id: int,
product_id: int | None = None,
area_id: int | None = None,
subarea_id: int | None = None,
competence_id: int | None = None,
client_id: int | None = None,
recent_months: int | None = Query(default=None, ge=1, le=120),
min_confidence: Literal["media", "alta"] | None = None,
require_code_match: bool = False,
limit: int = Query(default=8, ge=1, le=20),
offset: int = Query(default=0, ge=0),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
filters = _search_filters_from_params(
product_id,
area_id,
subarea_id,
competence_id,
client_id,
recent_months,
min_confidence,
require_code_match,
)
rows = ai_retriever.search_similar_tickets(
ticket_id,
user_id=_web_actor_id(web_user),
limit=limit,
offset=offset,
filters=filters,
)
return {"items": _serialize(rows), "filters": filters, "limit": limit, "offset": offset}
@app.post("/api/feedback")
def create_feedback(
payload: FeedbackCreate,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
actor_id = _web_actor_id(web_user)
if payload.feedback == "favorite":
inserted = ai_retriever.record_favorite(
actor_id,
payload.suggested_ticket_id,
query_ticket_id=payload.query_ticket_id,
note=payload.note,
)
return {"ok": True, "inserted": inserted}
if payload.query_ticket_id is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="query_ticket_id is required")
ai_retriever.record_feedback(
actor_id,
payload.query_ticket_id,
payload.suggested_ticket_id,
payload.feedback,
note=payload.note,
)
return {"ok": True}
@app.get("/api/users")
def list_users(
status_filter: Literal["P", "A"] | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=120),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
where_parts: list[str] = []
params: list[Any] = []
if status_filter:
where_parts.append("u.status = %s")
params.append(status_filter)
if q:
like = f"%{q.strip()[:80]}%"
where_parts.append("(CAST(u.tid AS TEXT) LIKE %s OR u.username ILIKE %s OR u.surname ILIKE %s)")
params.extend([like, like, like])
where_sql = "WHERE " + " AND ".join(where_parts) if where_parts else ""
rows = _pg_fetch_all(
f"""
SELECT
u.tid::bigint AS tid,
u.username,
u.surname,
u.status,
u.user_admin,
u.created_at,
u.updated_at,
COUNT(f.fid)::integer AS filter_count,
COALESCE(s.notifications_enabled, TRUE) AS notifications_enabled,
COALESCE(s.high_gravity_only, FALSE) AS high_gravity_only
FROM users u
LEFT JOIN filters f ON f.tid = u.tid
LEFT JOIN ticket_bot_user_settings s ON s.user_id = u.tid
{where_sql}
GROUP BY
u.tid,
u.username,
u.surname,
u.status,
u.user_admin,
u.created_at,
u.updated_at,
s.notifications_enabled,
s.high_gravity_only
ORDER BY u.created_at DESC, u.tid DESC
LIMIT 300
""",
tuple(params),
)
return {"items": rows, "viewer_id": web_user["id"]}
@app.patch("/api/users/{tid}")
def update_user(
tid: int,
payload: UserPatch,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
fields = _model_fields_set(payload)
assignments: list[str] = []
params: list[Any] = []
if "status" in fields and payload.status is not None:
assignments.append("status = %s")
params.append(payload.status)
if "user_admin" in fields and payload.user_admin is not None:
assignments.append("user_admin = %s")
params.append(payload.user_admin)
if not assignments:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No changes supplied")
params.append(tid)
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute(
f"""
UPDATE users
SET {", ".join(assignments)},
updated_at = now()
WHERE tid = %s
RETURNING tid::bigint AS tid, username, surname, status, user_admin, created_at, updated_at
""",
tuple(params),
)
row = cursor.fetchone()
connection.commit()
cursor.close()
connection.close()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return {"user": _serialize(dict(row)), "viewer_id": web_user["id"]}
@app.get("/api/users/{tid}/filters")
def list_user_filters(
tid: int,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
rows = _pg_fetch_all(
"""
SELECT fid::bigint AS fid, tid::bigint AS tid, sort, sort_value, created_at
FROM filters
WHERE tid = %s
ORDER BY sort, created_at DESC
""",
(tid,),
)
return {"items": [_decorate_filter(row) for row in rows], "viewer_id": web_user["id"]}
@app.post("/api/users/{tid}/filters", status_code=status.HTTP_201_CREATED)
def add_user_filter(
tid: int,
payload: FilterCreate,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
sort_value = _normalize_filter_value(payload.sort_value)
if _as_int(sort_value) is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filter value must be a numeric id")
connection = _pg_connection()
cursor = connection.cursor(cursor_factory=DictCursor)
cursor.execute("SELECT 1 FROM users WHERE tid = %s", (tid,))
if not cursor.fetchone():
cursor.close()
connection.close()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
cursor.execute(
"""
INSERT INTO filters (tid, sort, sort_value)
VALUES (%s, %s, %s)
ON CONFLICT (tid, sort, sort_value) DO NOTHING
RETURNING fid::bigint AS fid, tid::bigint AS tid, sort, sort_value, created_at
""",
(tid, payload.sort, sort_value),
)
row = cursor.fetchone()
inserted = row is not None
if not row:
cursor.execute(
"""
SELECT fid::bigint AS fid, tid::bigint AS tid, sort, sort_value, created_at
FROM filters
WHERE tid = %s AND sort = %s AND sort_value = %s
""",
(tid, payload.sort, sort_value),
)
row = cursor.fetchone()
connection.commit()
cursor.close()
connection.close()
return {"filter": _decorate_filter(_serialize(dict(row))), "inserted": inserted, "viewer_id": web_user["id"]}
@app.delete("/api/users/{tid}/filters/{fid}")
def delete_user_filter(
tid: int,
fid: int,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
connection = _pg_connection()
cursor = connection.cursor()
cursor.execute("DELETE FROM filters WHERE tid = %s AND fid = %s", (tid, fid))
deleted = cursor.rowcount
connection.commit()
cursor.close()
connection.close()
if deleted == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Filter not found")
return {"ok": True, "deleted": deleted, "viewer_id": web_user["id"]}
@app.get("/api/users/{tid}/settings")
def get_user_settings(
tid: int,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
return {"settings": _serialize(ai_retriever.get_user_bot_settings(tid)), "viewer_id": web_user["id"]}
@app.patch("/api/users/{tid}/settings")
def update_user_settings(
tid: int,
payload: SettingsPatch,
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
fields = _model_fields_set(payload)
updates = {field: getattr(payload, field) for field in fields}
if not updates:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No changes supplied")
settings = ai_retriever.update_user_bot_settings(tid, **updates)
return {"settings": _serialize(settings), "viewer_id": web_user["id"]}
@app.get("/api/taxonomy/{kind}")
def taxonomy(
kind: Literal["products", "competences", "areas", "subareas", "statuses"],
q: str | None = Query(default=None, max_length=120),
area_id: int | None = None,
limit: int = Query(default=300, ge=1, le=1000),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
table_map = {
"products": ("prodotti", "PRODOTTO"),
"competences": ("competenze", "COMPETENZA"),
"areas": ("aree", "AREA"),
"subareas": ("sottoaree", "SOTTOAREA"),
"statuses": ("stati", "STATO"),
}
table, label_column = table_map[kind]
where_parts: list[str] = []
params: list[Any] = []
if q:
where_parts.append(f"{label_column} LIKE %s")
params.append(f"%{q.strip()[:80]}%")
if kind == "subareas" and area_id is not None:
where_parts.append("AREA = %s")
params.append(area_id)
where_sql = "WHERE " + " AND ".join(where_parts) if where_parts else ""
rows = _mysql_fetch_all(
f"""
SELECT ID AS id, {label_column} AS label
FROM {table}
{where_sql}
ORDER BY {label_column}
LIMIT %s
""",
tuple(params + [limit]),
)
return {"items": rows, "viewer_id": web_user["id"]}
@app.get("/api/reports/no-results")
def no_result_report(
limit: int = Query(default=20, ge=1, le=100),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
return {"items": _serialize(ai_retriever.get_no_result_searches(limit)), "viewer_id": web_user["id"]}
@app.get("/api/reports/negative-feedback")
def negative_feedback_report(
limit: int = Query(default=20, ge=1, le=100),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
return {"items": _serialize(ai_retriever.get_negative_feedback(limit)), "viewer_id": web_user["id"]}
@app.get("/api/reports/top-useful")
def top_useful_report(
limit: int = Query(default=20, ge=1, le=100),
web_user: dict[str, Any] = Depends(current_web_user),
) -> dict[str, Any]:
return {"items": _serialize(ai_retriever.get_top_useful(limit)), "viewer_id": web_user["id"]}
def _serve_spa_path(full_path: str) -> FileResponse:
if full_path.startswith("api/"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR in candidate.parents:
return FileResponse(candidate)
index = STATIC_DIR / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Web UI was not built")
@app.get("/", include_in_schema=False)
def root() -> FileResponse:
return _serve_spa_path("index.html")
@app.get("/{full_path:path}", include_in_schema=False)
def spa(full_path: str) -> FileResponse:
return _serve_spa_path(full_path)