commit 71c87979c039bf188d32909ba972a4cedca936f1 Author: RGalyaviev Date: Wed Sep 3 11:06:14 2025 +0300 feat(ui): modern theme, dark/light toggle, polished templates diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f071f44 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "chatgpt.openOnStartup": true, + // Codex - OpenAI's coding agent: run without prompts in this workspace + "codex.approvalPolicy": "never", + // Filesystem access: use with care; consider "workspace-write" if you prefer safer mode + "codex.sandboxMode": "danger-full-access", + // Allow network calls (fetch deps/APIs) + "codex.networkAccess": "enabled" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd10ca1 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +Zammad Reports (Flask) + +Quickstart + +- Create and activate venv (optional). +- Install deps: pip install -r requirements.txt +- Configure env variables (recommended): + - ZAMMAD_URL=https://task.4mont.ru + - ZAMMAD_TOKEN=your_token_here +- Run: python app.py + +Environment + +- FLASK_SECRET_KEY: optional, for sessions/flash. +- ZAMMAD_URL: base URL of Zammad (e.g. https://task.4mont.ru). +- ZAMMAD_TOKEN: personal access token with API rights. + +Features + +- Totals: total, open vs closed. +- Grouping: by agent (owner), by group, by state, by priority. +- Trend: tickets per day in range. +- HTML charts (bar/line) with Chart.js + Bootstrap UI. +- JSON API: `/api/report.json` aggregates everything for the period. diff --git a/app.py b/app.py new file mode 100644 index 0000000..8f4d5af --- /dev/null +++ b/app.py @@ -0,0 +1,7 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + import os + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5aa4353 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/__pycache__/__init__.cpython-310.pyc b/app/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0354e5f Binary files /dev/null and b/app/__pycache__/__init__.cpython-310.pyc differ diff --git a/app/__pycache__/routes.cpython-310.pyc b/app/__pycache__/routes.cpython-310.pyc new file mode 100644 index 0000000..e9fe51e Binary files /dev/null and b/app/__pycache__/routes.cpython-310.pyc differ diff --git a/app/clients/__pycache__/zammad.cpython-310.pyc b/app/clients/__pycache__/zammad.cpython-310.pyc new file mode 100644 index 0000000..11b178f Binary files /dev/null and b/app/clients/__pycache__/zammad.cpython-310.pyc differ diff --git a/app/clients/zammad.py b/app/clients/zammad.py new file mode 100644 index 0000000..0e640a0 --- /dev/null +++ b/app/clients/zammad.py @@ -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 + diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..6920f9f --- /dev/null +++ b/app/routes.py @@ -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) + diff --git a/app/services/__pycache__/aggregations.cpython-310.pyc b/app/services/__pycache__/aggregations.cpython-310.pyc new file mode 100644 index 0000000..802ae7e Binary files /dev/null and b/app/services/__pycache__/aggregations.cpython-310.pyc differ diff --git a/app/services/aggregations.py b/app/services/aggregations.py new file mode 100644 index 0000000..a20ac2e --- /dev/null +++ b/app/services/aggregations.py @@ -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 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..98f7101 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=2.3 +requests>=2.31 diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..05263e0 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,126 @@ +/* Base Theme Variables */ +:root { + --bg: #0b1020; + --bg-soft: #0f152b; + --text: #e6e9ef; + --muted: #aab0bc; + --card: rgba(255, 255, 255, 0.06); + --card-border: rgba(255, 255, 255, 0.12); + --brand: #4f8cff; + --brand-2: #8a5cff; + --accent: #22c55e; + --danger: #ef4444; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.35); +} + +/* Light theme overrides */ +html[data-theme="light"] { + --bg: #f6f7fb; + --bg-soft: #ffffff; + --text: #1b2430; + --muted: #5b6573; + --card: rgba(0, 0, 0, 0.04); + --card-border: rgba(0, 0, 0, 0.08); + --brand: #0d6efd; + --brand-2: #6f42c1; +} + +/* Global */ +html, body { + height: 100%; +} +body { + color: var(--text); + background: radial-gradient(1200px 600px at 10% -10%, rgba(79, 140, 255, .25), transparent 60%), + radial-gradient(900px 500px at 100% 0%, rgba(138, 92, 255, .18), transparent 60%), + var(--bg); + background-attachment: fixed; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + letter-spacing: 0.2px; +} + +.container { + max-width: 1100px; +} + +/* Navbar */ +.navbar { + background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0)); + border-bottom: 1px solid var(--card-border); + backdrop-filter: blur(8px); +} +.navbar .navbar-brand { + letter-spacing: 0.3px; + font-weight: 700; +} + +/* Card / Surfaces */ +.card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 14px; + box-shadow: var(--shadow); + overflow: hidden; +} +.card-header { + background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0)); + border-bottom: 1px solid var(--card-border); + color: var(--text); + font-weight: 600; +} + +/* Buttons */ +.btn-primary { + --bs-btn-color: #fff; + --bs-btn-bg: var(--brand); + --bs-btn-border-color: var(--brand); + --bs-btn-hover-bg: color-mix(in oklab, var(--brand), #000 15%); + --bs-btn-hover-border-color: color-mix(in oklab, var(--brand), #000 20%); + --bs-btn-focus-shadow-rgb: 79, 140, 255; +} +.btn-outline-secondary { + --bs-btn-color: var(--text); + --bs-btn-border-color: var(--card-border); + --bs-btn-hover-bg: var(--card); + --bs-btn-hover-border-color: var(--brand); +} + +/* Forms */ +.form-label { color: var(--muted); font-weight: 600; } +.form-control, .form-select { + color: var(--text); + background: var(--bg-soft); + border: 1px solid var(--card-border); +} +.form-control:focus, .form-select:focus { + background: var(--bg-soft); + border-color: var(--brand); + box-shadow: 0 0 0 .25rem rgba(79,140,255,.15); +} + +/* Tables */ +.table { color: var(--text); } +.table > :not(caption) > * > * { background-color: transparent; } +.table-light { background: rgba(255,255,255, .06) !important; color: var(--text); } +.table-hover > tbody > tr:hover > * { background: rgba(79, 140, 255, 0.08); } + +/* Alerts */ +.alert { border: 1px solid var(--card-border); border-radius: 12px; } + +/* Chart container */ +.chart-wrap { position: relative; height: 380px; } + +/* Subtle animations */ +.fade-in { animation: fade-in .3s ease-out; } +@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + +/* Theme toggle */ +.theme-toggle { + display: inline-flex; + align-items: center; + gap: .5rem; + color: var(--text); + border: 1px solid var(--card-border); + background: var(--card); +} + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..020cfcf --- /dev/null +++ b/templates/base.html @@ -0,0 +1,70 @@ + + + + + + + Zammad Reports by Ruslan + + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c6b6988 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
Параметры отчёта
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
Подсказка
+
+

Выберите диапазон дат и тип группировки, затем нажмите «Построить отчёт».

+

Переключайте тёмную/светлую тему в шапке справа.

+
+
+
+
+{% endblock %} diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..cc9cce3 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,107 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ title }}

+ На главную +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
Сводка
+
+
    +
  • Всего тикетов{{ overview.total }}
  • +
+
+
+
+
+ +
+
+
+
По агентам
+
+ + + + {% for name, count in by_agent %} + + {% endfor %} + +
АгентКоличество
{{ name }}{{ count }}
+
+
+
+
+
+
По группам
+
+ + + + {% for name, count in by_group %} + + {% endfor %} + +
ГруппаКоличество
{{ name }}{{ count }}
+
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} +