From 71c87979c039bf188d32909ba972a4cedca936f1 Mon Sep 17 00:00:00 2001 From: RGalyaviev Date: Wed, 3 Sep 2025 11:06:14 +0300 Subject: [PATCH] feat(ui): modern theme, dark/light toggle, polished templates --- .vscode/settings.json | 9 ++ README.md | 24 ++++ app.py | 7 + app/__init__.py | 32 +++++ app/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 942 bytes app/__pycache__/routes.cpython-310.pyc | Bin 0 -> 3566 bytes .../__pycache__/zammad.cpython-310.pyc | Bin 0 -> 3919 bytes app/clients/zammad.py | 124 +++++++++++++++++ app/routes.py | 129 ++++++++++++++++++ .../__pycache__/aggregations.cpython-310.pyc | Bin 0 -> 3446 bytes app/services/aggregations.py | 77 +++++++++++ requirements.txt | 2 + static/css/styles.css | 126 +++++++++++++++++ templates/base.html | 70 ++++++++++ templates/index.html | 46 +++++++ templates/report.html | 107 +++++++++++++++ 16 files changed, 753 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-310.pyc create mode 100644 app/__pycache__/routes.cpython-310.pyc create mode 100644 app/clients/__pycache__/zammad.cpython-310.pyc create mode 100644 app/clients/zammad.py create mode 100644 app/routes.py create mode 100644 app/services/__pycache__/aggregations.cpython-310.pyc create mode 100644 app/services/aggregations.py create mode 100644 requirements.txt create mode 100644 static/css/styles.css create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/report.html 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 0000000000000000000000000000000000000000..0354e5f01bfba93181bdc9752e3e9fb890ab7326 GIT binary patch literal 942 zcmY*YO>f#j5Z$$nF$6*qsS>9idgBlxr3q1^stO8_R**yj(uft(%3+sa;t#UBrUIq6 zUUTmej{ODwFTVDaThBd29TSkqSTnotd1iKIybiM2Gy>WF`+E@T2>pq{x&VhxK;h0Bf%98N|Sd>CA8wo=HsDC%Dnqn8}hq43cEHg$mXa#G{J>juJK3mUmAW zARzM_zo9py$P`?K!tcdiD~Vl_pr@enCm4kkW|(PAf7HJtr499&!3>s|YY&Jf9?;O7 zC7HS8;qJ^_>)-W|o&(UmSj@JA)Ry?d3mSNdPoL}>qW!2^!$JwE~ z&5LQKSlm-V3%i%zK=><>GX~7azXAIvM!+e?`*!H6NMkC3WQvo73Lq&-IWp)6!m|}gDaYfCyUHdzg5Nq) zaY1j~kdK7pD`6~3iFA83`h`Re(7%J;ara7cA-l~p+nv~BhmX6Jpci`FSF%fN&$C$< z0=kqszN08DjwVqW1`ZWa;4R1^-+|f0TR5+qSi`Zx^K3llQ}I5Jjpwv!Xkt4)-x6^M zvEmluaz!SFiGF<97tp_Fw+&=8y&I|#IktnKwwzj4gR%+;YKM|(lgsUJ zR?cp@g%cUQz%Y72fj-0yr1r^gPGYq|n%IAV`#P_E%2Nvj2v7sPoHJZWD>-@S5;{Ed z&2Z+-nRC85ibh5X3jFv#{(9}xQAPPXHimx|G+s~?68{sFMTIJq1WHJ3LQq!&)z;vx z1$vmXbD?1y64wJWv}`NP+xf6y7s8@l3`=$?9I;2jvRw{G?NJz)qef5($Lz6i+#Z*0 zGdK`V*po!@ENXdE>#99XpP@xsx~z3GiA9oZzbOh<1k>%=}pD|E~!AjhRVAmxOV zlTuDeISumQJ<~o+tKJcMXjXxlj?%+0(-C@9&U8#7#{fA_YZ5t5)di(~;$!qhort2_ zY)0JWe$#ESt`3)|#xn`$siTv51?X{TS9S? zEMj0)$z5G#-fCu+t=;sNg~2@DW=&Rx&l_m`@K1-vi+Gq*pm2qg>nbJH?5$Rnb=7~WkWAW0J zm)xM^uKC{D(z&S74#E0(DYJeFe6z%&HuvJ0R!5jlLv9YDPl;6!Q-n>!>j^pa)CO{Z z%MBa?g+naLEr99rtyLAO-_>9fx%qCv2j9OwbB%|A7&~|V!rbD;tIoN(tLL2yS1w-? zqkEvMm+PuMM!glc9dL)jSve~#3hmK75RbqCCaf$Sz$Q@J*?Xp;V?|CbeE`%LlS@@q zV!>nln;n+j+~fs-+*KkJ7)T@ArR^ZuQ^jznQ+9KB~d%edySN z_oMXPb`3xI(>Bz)-0H!}!N?(238SNv`{#|7L%^ z|Arj)MQI~U`~iG@2RiP6rE765{R4ClVeONjIbZs?e+#UAgc2sfZJ>qE;o#px$4A(d z+j($$XsrO;qN7IQR0!xw99~J1b(8KpX+{OYkQH@gd|XSFV!{$fQlu+tyXk$pF`=zNz-qL}AkjiBE71iEmvc3fFm# zn(JhpEGUV}jWxnfQoW~gb6x3aT`SQxtVBr&e67E#G;6xT^PL5v^m0V$PQW}?l6y<} zqe}C8XM0sKlpg^-_i{hWEezf_$xVe9Hj0U!XfU6VsI+iH1+pk?lm?jDoCUN9XbaGh z9rSB}mH_=JpyeI(SwKes{W+kcJLq9R%YgnC(8>;40dy45w*VbWbnuPYG-xF;=vY

9o^784L zZBVzl>M?cKjTmW+a z1h4D_xUmY&`|3Qyc$MsQp9u2$5MxQQvStG}T&C`sAHc9ra1u-D9?ZgiW;Tc#ZV;aV zB^z`Vt|aY-2dvFJ$jI3HTr=2LaM`bJugx&&_pQy40;>_5Yt4o0H&^NULq7=JWvj8!Rz1!zXIUNvdX*u!$%{sm zW6x!cYYz7tH#{CQyorPccdHnW+i?3bzw0^M1hl(^JTOrPUKt`Cd3F*6vT+*B16HD< zOo4P99yxF~`d%!I7)S;1oDa1@Ebo;1$i5v5(iP^wBtbm6KcPdMy28jDAdJD*(KkbO z?cPc9grOur4nr@-OCU;WiIj<@7Dx%CP7Ju8Du7hTL1Nuf|5MT}4f=7+p1%?)sb%=~ zn0Acdo%Zx&e9F$k0^Q0fx8)D0a|Y|0J^YDBfF~gNQ-Jb+@#pc3KYd_LYOX-V!gXXz z;L6-KS8`*+9B@yL>PZe5#z>3}3-Ek~gI3tl2Al}kfM-A(a0+O^cu*Ev_l!*_d4Wlc zH3jYy12|8zl;kY4XC-E0(fmE=r-fOCYl(rZUN6q?JJswMbCCEE>z>#lpqksHwE+j! zT;|oVQiKYT`oDqPt{X$ATLX{nqlvo8UO;pXgq@Q)Qm@GPXU`-4breMuSXi-dp!g<= zZ-Ed-yM-(7U*g+1Kr+#HQ2Q>3r?YYvyS|44=~9?2w-ZDz-8E-l%8NmgI~Wzq@JWok zFo;NqKm+2dq*_E*3lN{#1Fa|ef34EEj9PX9K7b96!An-7^_nO+&Pp4~Ue9qDGMKP% zL);HNp;9gk-f00hGYvn%8aS)5Z!jz;vszA8bx0UP1R))Uq+yq{3N@bT%3?e#f{`U` z3le(GZvg#N@MCnUupFo9hG2^@;4*NQTN%sbWXUT_K*>E~)NzLXs{qsGF<}D9Jq~$J wCiH-^|jxS;YdePM69505((F{{R30 literal 0 HcmV?d00001 diff --git a/app/clients/__pycache__/zammad.cpython-310.pyc b/app/clients/__pycache__/zammad.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11b178f068fa92ee4c463ff122dce0ffaaa4cbe9 GIT binary patch literal 3919 zcmbVPO^n;d6`mo76qnMj*LJ+|Pkt2Vr&f}Bouo|x*G*$2shc3xrnZ|P^11}0p?A5K zD0xWPS%E4HZ`w`YJl4ATQ8tl9T2Cp*4(>fT*ByX`cZ*We$)v_8E?sm&= zi-u@B4X5ok+;*u^;_M8QLOLHY=|tX|-I&7Il_iWzYgWUT);U)5?$VQLT$jTn>11IR zcapST6WTtVtZMOk+|0CiJ5Dq0Ep%xyY&B`e&WmI4D%n>76R`%D3^s9Sw{*D?w%eh6 zL#d9c*KF+tK@zs3Akcmgv^%obBEAv?S9)QqSotGUWhgRa>tyrf;xE!jrHg0Jgss)^ zY8+i%e7)1`wWB0U7Ypr+VYj>3Y{h1(ZytEOyQ)giLjRN;=rO^~%me0+-TN-Y#^yh5 zTqPQ|d`5mpPRp5(oQ9CcWKABBbKo8MtUM$SeSJF;`U{0UzWy>8E_sy(!PdA%UH>;IlINl=eYC*51 zJr!N)MQN5cO6Q_91!#0B9Wl#MC=pKW_PR36B3%x82yf5~o68Y!u%tS@?s${}kkR&N zPta8{9-gfh69AJ)qt+!g19zxdB4gMnLLy-;PU0*GXq3)@RJhMQ?pYQWUyFOL=W-V_ z{R2B=b9}U`u0=P@mbKdrRoG6q4vrGw0%gSoyz%o@B+12#ilQ?SE*BG&(+hY6EB4*}5_|L%AupiU z(j~)Y9qo02!fv<}X**7`t%s=Q?GAL4O2z0RP>oaaUl`tO&4zYniTt~cvWE6;z zojVk%Po06g#0Dkl47@3pyTE`aOSkO76k$Yq`IMac#2WY+&;86=v($X<56a1lW^7{| z5VtO*zk$TZ_;l_|AI~f!b&Hkq_DUTqWwWw>d!@3yvS>6^me_`sa9C4LETVa7-N~^V zSaI|68qe*^Om$#o=^TTV&JWnYyK=ZTUB64D7*ZxJMg6(?up7@`ePKR}o0p?3olm1sHJ59A3_~-mh3Z^OwLoaXT1lNC z1%8~y36PXD0iqW5BO#H6>KHA$09BMoZMUMNRwpe>L;U9*d z4L`3A|1|t!czgI448GX>c=I>I+x?kQQ0W!YdF@^5wM-{(OkIl67pSRgr`1`ClbWL_ zXH7&+ixpPO*?c!Tze z?h?yy^y^peK;Qr6*T=rahoauw54|HF{(1BI=GySi@XqFM`+G*-Gp42U>SqvypQym? z@{^*zKl0K7t&^9`hTTr`ix(v?orlafmuT2==j)}zAqTW(roS@8O6A$AjO7&bfxn( zPf;h{5$Ye)@HCO{6CpR7G(jb-5`T_JQLa+b(b%)&kreTUlTqcQ(`4FfWxKIKl`xBb z2`%X}AVum>@;rn}My=CF&6-6$E1KwG%cRu439uLyx;#FReM?3u%BUJL;0W%c47js- zefZbm--vGB7=DKT?f&6iUfNb3!%3s0cpNHrc#4h}6G_paiEm7b>9M)wrIcjPg7gnh zyxN2i&L!q_X`Vz$WFn#Pk;c?MkQ%Q;#GG1;h-oEH(W()#jfi@IZk{q7$%wr~5{9GR zRK(eAc$p-HG9~a$S~9aFiP;63cwpAR;r=Y8J7kR~?#~(xzM^zhQ$(nLt0#z@AaaIC zoygCLyieo;k^3iV!_k>XO@q)VC5Lznkl9 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 0000000000000000000000000000000000000000..802ae7eba8e73adc681673c19fc67eb2f0ccc8b0 GIT binary patch literal 3446 zcmbVO%WvGq8RrZiOHr$hEX%SbJJC9RG@BZ3UagZrb?sVhVW3c(*y(|mu%$WlmKC{N z4Y@KTaDgCp&;mveK@YtcfppZZm!d%b3hO%8o_y-TH=p|ZhT4_Zi4C`0@Y~-E=kd+= z_|2D1O?d{M@IU|j!v&y!Q{(7kqH)bISiS=w48am3WjiJ$e=})G0zm9LVaFVIC;WvPrVoEd~TK*g2te6(ZfTn?F#BrcwK(pcm z&K3Y2#Nm2Wl(pWau&JXO8?Xc3`;ezk7T{dt^2iV+Yy#-ZWhPNq*?O`vI}xmxWcMT4a%CL@WP`*>2;ZYw z?U20T*Q<4_mSTnCWcHjk-!o(r!=GOdKVAJIk7U03`LBXxE7**q&DG^B?5A);zFIkD zHR$zLF=#UmqkJ{!bY#@2Tz|Q@rDpOzJSXGrsGV)npy)n@d_D`|G7CSKO|u!i9`kse z$+v0aBQA@(#oCz5C?^hH_Us)r3gm_058=3FxQ?9nC*vLyrm!BmCEsDYx*rC&S3)x4 z@S!-c@!o>(pDh4%u*BNLfWg;DF!>Iv8q0N0ayWfo@yriAB$K+|u*yD;{eowv$ zs4Z5BvuG-BDM4_CS=zfgkM=X#ZNb<+b9bv9bYQ;AOGy?6Nq!YzIB-loIwOVVaZ-=Y z8IyX`t9*bH{@K_uN+yhTQ#MN0WdcE2^1o_j_qbMZ1&KJ(hhy5^AdOn4q&1Y;N1U+` z`s=7@qE8;^OG&BqC0l5H-}!!hM+31FX{qeEh|+w|JPbEPEd^O#8RWguF7EL7r)Uog zVusBzc?IC5PL}8K^;OQ37ia)iZbxSQ-q1zzB6{TyNHj-+jw)LaZxMJGLVH9{{GU7` ze@F{lf`FO5YU43GcMvH*qA~jLO+tF}_kk9v^#R29Zay-v{E)`|m_(DrxEb}HI`TfI zk4q3^CZyOIQQObah>Zc)bha=sDa1>2N5>85%qlHlA~X)FL(Y`8uy(nyPa2>sd&$_a zN=gG~MIOUS%A%vHTEDyAMOx@^lXLjc;*x?#=SIsp3eb3>!~c|;&5_UnNTH!A zj2Iv%;;OizjsUTk{24mN&R0Xn=zPCN19Z)awht)V(r47?+9k@iLEm5nr1cyqn}f0) zP__fgK3h7PvM-K^+9|oFY`y`6UiUGb{(!Kx3FHiRl*dUBm1!nEFhPGB9Z$a9|LV!V z_P^YJwExxqqbL8UI{)_M??*e4lp}M=Gnmz?9oo!yh^!+~Eq!P^9Xw6iQwwdo25eVR zJ6#!qVlyWNU*uPh$!q8yv*S@deeG16?8!Kjak2G!+4c!Gt+H(qnhxjw{|PrWTw4|(VmRzbyxv#aa5hlZ&Ag+E}3 zPItxkLK&fy5bXd((gA)-*R77sF&#-?x{_{@j#LGe$e(#MTf8bubaa8+`1)oQXiW^N zt_pW43DUbFxcW;JU;(#pQ2k1ty-K*45=5 zT|Q#>wM$J6sn%Eok!I1At}GlkSI%aT z^rIZP{#)AJNrJmklFQp@eCHfwgm#g&acjp>eiK7-qPRM%<0SHTfJ0WDqUrhyr&?FO zL4uC_s_*CMyH#0L&)2gKdPzA+xLWHo8hcWC?e@Jss_3ZQmgH#V3ItdYgZ;TE)MS_? zNfhcrUzY-vZygkoaknEW2_zjr5?Mcs70$-jruI>i-42q3pu| literal 0 HcmV?d00001 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 %} +