feat(ui): modern theme, dark/light toggle, polished templates
This commit is contained in:
32
app/__init__.py
Normal file
32
app/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import datetime as dt
|
||||
from flask import Flask
|
||||
|
||||
from .routes import web_bp
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__, template_folder="../templates", static_folder="../static")
|
||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret")
|
||||
|
||||
# Base config
|
||||
app.config.setdefault("ZAMMAD_URL", os.environ.get("ZAMMAD_URL", "https://task.4mont.ru"))
|
||||
app.config.setdefault(
|
||||
"ZAMMAD_TOKEN",
|
||||
os.environ.get(
|
||||
"ZAMMAD_TOKEN",
|
||||
"L5A9p_7Ge6YSR-VhAFysu6vqhu5qfWkri6ITdZLcKdZW7tes342KqHmNY6Ao2jY_",
|
||||
),
|
||||
)
|
||||
|
||||
# Defaults for forms
|
||||
app.config.setdefault("DEFAULT_DATE_TO", dt.date.today().isoformat())
|
||||
app.config.setdefault(
|
||||
"DEFAULT_DATE_FROM",
|
||||
(dt.date.today() - dt.timedelta(days=30)).isoformat(),
|
||||
)
|
||||
|
||||
# Register blueprints
|
||||
app.register_blueprint(web_bp)
|
||||
|
||||
return app
|
||||
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
BIN
app/__pycache__/routes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/clients/__pycache__/zammad.cpython-310.pyc
Normal file
BIN
app/clients/__pycache__/zammad.cpython-310.pyc
Normal file
Binary file not shown.
124
app/clients/zammad.py
Normal file
124
app/clients/zammad.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ZammadError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ZammadClient:
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self.base = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Token token={token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
# Simple in-memory caches
|
||||
self._user_cache: Dict[int, str] = {}
|
||||
self._group_cache: Dict[int, str] = {}
|
||||
self._state_cache: Dict[int, Dict[str, Any]] = {}
|
||||
self._priority_cache: Dict[int, str] = {}
|
||||
|
||||
# HTTP helper
|
||||
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
url = f"{self.base}{path}"
|
||||
try:
|
||||
r = self.session.get(url, params=params, timeout=30)
|
||||
except requests.RequestException as e:
|
||||
raise ZammadError(str(e))
|
||||
if r.status_code >= 400:
|
||||
raise ZammadError(f"{r.status_code} {r.text}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
raise ZammadError("Invalid JSON from Zammad")
|
||||
|
||||
# Tickets search by created range
|
||||
def search_tickets(self, date_from: str, date_to: str, per_page: int = 200) -> List[Dict[str, Any]]:
|
||||
start = date_from[:10]
|
||||
end = date_to[:10]
|
||||
query = f"created_at:[{start} TO {end}]"
|
||||
|
||||
tickets: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
params = {"query": query, "per_page": per_page, "page": page}
|
||||
data = self._get("/api/v1/tickets/search", params=params)
|
||||
|
||||
if isinstance(data, list):
|
||||
batch = data
|
||||
elif isinstance(data, dict):
|
||||
if isinstance(data.get("tickets"), list):
|
||||
batch = data["tickets"]
|
||||
elif isinstance(data.get("rows"), list):
|
||||
batch = data["rows"]
|
||||
else:
|
||||
batch = data.get("data", []) if isinstance(data.get("data"), list) else []
|
||||
else:
|
||||
batch = []
|
||||
|
||||
tickets.extend(batch)
|
||||
if len(batch) < per_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return tickets
|
||||
|
||||
# Lookups
|
||||
def user_name(self, user_id: Optional[int]) -> str:
|
||||
if not user_id:
|
||||
return "Без владельца"
|
||||
if user_id in self._user_cache:
|
||||
return self._user_cache[user_id]
|
||||
data = self._get(f"/api/v1/users/{user_id}")
|
||||
name = (data.get("fullname") or data.get("firstname") or data.get("login") or str(user_id)).strip()
|
||||
self._user_cache[user_id] = name
|
||||
return name
|
||||
|
||||
def group_name(self, group_id: Optional[int]) -> str:
|
||||
if not group_id:
|
||||
return "Без группы"
|
||||
if group_id in self._group_cache:
|
||||
return self._group_cache[group_id]
|
||||
data = self._get(f"/api/v1/groups/{group_id}")
|
||||
name = (data.get("name") or str(group_id)).strip()
|
||||
self._group_cache[group_id] = name
|
||||
return name
|
||||
|
||||
def state(self, state_id: Optional[int]) -> Dict[str, Any]:
|
||||
if not state_id:
|
||||
return {"name": "unknown", "state_type": "unknown"}
|
||||
if state_id in self._state_cache:
|
||||
return self._state_cache[state_id]
|
||||
data = self._get(f"/api/v1/ticket_states/{state_id}")
|
||||
# Try to resolve state_type name if available
|
||||
stype = data.get("state_type") or data.get("state_type_id")
|
||||
state_type_name = None
|
||||
if isinstance(stype, dict):
|
||||
state_type_name = stype.get("name")
|
||||
elif isinstance(stype, int):
|
||||
try:
|
||||
tdata = self._get(f"/api/v1/ticket_state_types/{stype}")
|
||||
state_type_name = tdata.get("name")
|
||||
except ZammadError:
|
||||
state_type_name = None
|
||||
result = {"name": data.get("name", str(state_id)), "state_type": state_type_name or "unknown"}
|
||||
self._state_cache[state_id] = result
|
||||
return result
|
||||
|
||||
def priority_name(self, priority_id: Optional[int]) -> str:
|
||||
if not priority_id:
|
||||
return "Без приоритета"
|
||||
if priority_id in self._priority_cache:
|
||||
return self._priority_cache[priority_id]
|
||||
data = self._get(f"/api/v1/ticket_priorities/{priority_id}")
|
||||
name = (data.get("name") or str(priority_id)).strip()
|
||||
self._priority_cache[priority_id] = name
|
||||
return name
|
||||
|
||||
129
app/routes.py
Normal file
129
app/routes.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Dict, Any
|
||||
|
||||
from flask import Blueprint, current_app, render_template, request, redirect, url_for, jsonify, flash
|
||||
|
||||
from .clients.zammad import ZammadClient, ZammadError
|
||||
from .services import aggregations as agg
|
||||
|
||||
|
||||
web_bp = Blueprint("web", __name__)
|
||||
|
||||
|
||||
def _client() -> ZammadClient:
|
||||
return ZammadClient(current_app.config["ZAMMAD_URL"], current_app.config["ZAMMAD_TOKEN"])
|
||||
|
||||
|
||||
@web_bp.route("/")
|
||||
def index():
|
||||
return render_template(
|
||||
"index.html",
|
||||
default_date_from=current_app.config["DEFAULT_DATE_FROM"],
|
||||
default_date_to=current_app.config["DEFAULT_DATE_TO"],
|
||||
)
|
||||
|
||||
|
||||
AVAILABLE_GROUPS: Dict[str, Dict[str, Any]] = {
|
||||
"overview": {"title": "Общее количество тикетов"},
|
||||
"by_agent": {"title": "Тикеты по агентам"},
|
||||
"by_group": {"title": "Тикеты по группам"},
|
||||
"by_state": {"title": "Тикеты по статусам"},
|
||||
"open_closed": {"title": "Открытые vs Закрытые"},
|
||||
"by_priority": {"title": "Тикеты по приоритетам"},
|
||||
"by_day": {"title": "Динамика: тикеты по дням"},
|
||||
}
|
||||
|
||||
|
||||
@web_bp.get("/report")
|
||||
def report():
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
group_by = request.args.get("group_by", "overview")
|
||||
|
||||
if not date_from or not date_to:
|
||||
flash("Укажите период дат.", "warning")
|
||||
return redirect(url_for("web:index"))
|
||||
|
||||
if group_by not in AVAILABLE_GROUPS:
|
||||
group_by = "overview"
|
||||
|
||||
z = _client()
|
||||
try:
|
||||
tickets = z.search_tickets(date_from, date_to)
|
||||
except ZammadError as e:
|
||||
flash(f"Ошибка Zammad API: {e}", "danger")
|
||||
return redirect(url_for("web:index"))
|
||||
|
||||
overview = agg.summarize_overview(tickets, z)
|
||||
|
||||
# Determine main chart
|
||||
if group_by == "by_agent":
|
||||
data = agg.by_agent(tickets, z)
|
||||
elif group_by == "by_group":
|
||||
data = agg.by_group(tickets, z)
|
||||
elif group_by == "by_state":
|
||||
data = agg.by_state(tickets, z)
|
||||
elif group_by == "open_closed":
|
||||
data = agg.by_open_closed(tickets, z)
|
||||
elif group_by == "by_priority":
|
||||
data = agg.by_priority(tickets, z)
|
||||
elif group_by == "by_day":
|
||||
data = agg.by_day_created(tickets)
|
||||
else:
|
||||
data = {"Всего": overview["total"]}
|
||||
|
||||
labels, values = agg.dict_to_series(data)
|
||||
title = AVAILABLE_GROUPS[group_by]["title"]
|
||||
|
||||
# Supplemental tables
|
||||
by_agent = sorted(agg.by_agent(tickets, z).items(), key=lambda x: x[1], reverse=True)
|
||||
by_group = sorted(agg.by_group(tickets, z).items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
return render_template(
|
||||
"report.html",
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
group_by=group_by,
|
||||
title=title,
|
||||
chart_labels=labels,
|
||||
chart_values=values,
|
||||
overview=overview,
|
||||
by_agent=by_agent,
|
||||
by_group=by_group,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/api/report.json")
|
||||
def report_json():
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
group_by = request.args.get("group_by", "overview")
|
||||
if not date_from or not date_to:
|
||||
return jsonify({"error": "date_from and date_to are required"}), 400
|
||||
|
||||
z = _client()
|
||||
tickets = z.search_tickets(date_from, date_to)
|
||||
|
||||
overview = agg.summarize_overview(tickets, z)
|
||||
payload: Dict[str, Any] = {"overview": overview}
|
||||
|
||||
payload.update(
|
||||
{
|
||||
"by_agent": agg.by_agent(tickets, z),
|
||||
"by_group": agg.by_group(tickets, z),
|
||||
"by_state": agg.by_state(tickets, z),
|
||||
"open_closed": agg.by_open_closed(tickets, z),
|
||||
"by_priority": agg.by_priority(tickets, z),
|
||||
"by_day": agg.by_day_created(tickets),
|
||||
}
|
||||
)
|
||||
|
||||
# Optional: main_group result
|
||||
if group_by in payload:
|
||||
labels, values = agg.dict_to_series(payload[group_by])
|
||||
payload["chart"] = {"labels": labels, "values": values}
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
BIN
app/services/__pycache__/aggregations.cpython-310.pyc
Normal file
BIN
app/services/__pycache__/aggregations.cpython-310.pyc
Normal file
Binary file not shown.
77
app/services/aggregations.py
Normal file
77
app/services/aggregations.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from ..clients.zammad import ZammadClient
|
||||
|
||||
|
||||
def summarize_overview(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, Any]:
|
||||
total = len(tickets)
|
||||
open_count = 0
|
||||
closed_count = 0
|
||||
for t in tickets:
|
||||
st = z.state(t.get("state_id"))
|
||||
stype = (st.get("state_type") or "").lower()
|
||||
if "close" in stype or stype == "closed":
|
||||
closed_count += 1
|
||||
else:
|
||||
open_count += 1
|
||||
return {"total": total, "open": open_count, "closed": closed_count}
|
||||
|
||||
|
||||
def by_agent(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||
counts = Counter([t.get("owner_id") or 0 for t in tickets])
|
||||
return {z.user_name(uid if uid != 0 else None): c for uid, c in counts.items()}
|
||||
|
||||
|
||||
def by_group(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||
counts = Counter([t.get("group_id") or 0 for t in tickets])
|
||||
return {z.group_name(gid if gid != 0 else None): c for gid, c in counts.items()}
|
||||
|
||||
|
||||
def by_state(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
for t in tickets:
|
||||
st = z.state(t.get("state_id"))
|
||||
name = st.get("name", "unknown")
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def by_open_closed(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||
opened = 0
|
||||
closed = 0
|
||||
for t in tickets:
|
||||
st = z.state(t.get("state_id"))
|
||||
stype = (st.get("state_type") or "").lower()
|
||||
if "close" in stype or stype == "closed":
|
||||
closed += 1
|
||||
else:
|
||||
opened += 1
|
||||
return {"Открытые": opened, "Закрытые": closed}
|
||||
|
||||
|
||||
def by_priority(tickets: List[Dict[str, Any]], z: ZammadClient) -> Dict[str, int]:
|
||||
counts = Counter([t.get("priority_id") or 0 for t in tickets])
|
||||
return {z.priority_name(pid if pid != 0 else None): c for pid, c in counts.items()}
|
||||
|
||||
|
||||
def by_day_created(tickets: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = defaultdict(int)
|
||||
for t in tickets:
|
||||
created = t.get("created_at") or t.get("created")
|
||||
if not created:
|
||||
continue
|
||||
day = str(created)[:10]
|
||||
counts[day] += 1
|
||||
# sort by date
|
||||
return dict(sorted(counts.items(), key=lambda kv: kv[0]))
|
||||
|
||||
|
||||
def dict_to_series(d: Dict[str, int]) -> Tuple[List[str], List[int]]:
|
||||
labels = list(d.keys())
|
||||
values = list(d.values())
|
||||
return labels, values
|
||||
|
||||
Reference in New Issue
Block a user