From 866e2b44d5e06662bf768f3ea9d9df870d96ea55 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 29 Apr 2026 12:39:16 +0300 Subject: [PATCH] fix: localize admin UI and simplify employees navigation --- README.md | 2 +- app/admin.py | 31 ++++----- app/services/admin_data.py | 33 ++++++++++ app/static/admin.js | 2 +- app/templates/base.html | 9 ++- app/templates/dashboard.html | 34 +++++----- app/templates/directory.html | 88 +++++++++++++------------- app/templates/employee_detail.html | 19 +++--- app/templates/employees.html | 29 --------- app/templates/login.html | 10 +-- app/templates/runs.html | 26 ++++---- app/version.py | 6 +- pyproject.toml | 2 +- tests/test_admin_data.py | 12 ++++ tests/test_admin_templates.py | 32 ++++++++++ tests/test_api_mcp.py | 2 +- tests/test_employee_detail_template.py | 13 +++- 17 files changed, 204 insertions(+), 146 deletions(-) delete mode 100644 app/templates/employees.html create mode 100644 tests/test_admin_templates.py diff --git a/README.md b/README.md index 895e1a3..acb0984 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql docker compose down ``` -Версия сервиса: `0.2.5`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.2.6`. Админка всегда показывает версии backend и frontend в footer. diff --git a/app/admin.py b/app/admin.py index 23df36e..468749a 100644 --- a/app/admin.py +++ b/app/admin.py @@ -1,14 +1,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from sqlalchemy import desc, func, or_, select +from sqlalchemy import desc, func, select from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.db import SessionLocal, get_db from app.models import CrawlError, CrawlRun, Employee from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin -from app.services.admin_data import employee_detail_payload, list_employees_page, run_payload, stats_payload +from app.services.admin_data import employee_detail_payload, format_admin_datetime, list_employees_page, run_payload, stats_payload from app.services.crawl_control import get_running_run, run_crawl_if_idle from app.version import BACKEND_VERSION, FRONTEND_VERSION @@ -22,8 +22,9 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting counts = stats_payload(db) counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0 counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0 - runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(10)).all() - return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": run_payload(runs[0]) if runs else None}) + run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(10)).all() + runs = [run_payload(run) for run in run_models] + return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": runs[0] if runs else None}) @router.get("/login", response_class=HTMLResponse) @@ -57,18 +58,10 @@ def employees( request: Request, status: str | None = None, q: str | None = None, - db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ): require_admin(request, settings) - stmt = select(Employee) - if status: - stmt = stmt.where(Employee.status == status) - if q: - pattern = f"%{q}%" - stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern))) - items = db.scalars(stmt.order_by(Employee.full_name).limit(200)).all() - return _render(request, "employees.html", {"employees": items, "status": status or "", "q": q or ""}) + return RedirectResponse("/admin/directory", status_code=303) @router.get("/directory", response_class=HTMLResponse) @@ -133,7 +126,14 @@ def employee_detail( employee = db.get(Employee, employee_id) if not employee: return RedirectResponse("/admin/employees", status_code=303) - snapshots = sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20] + snapshots = [ + { + "captured_display": format_admin_datetime(snapshot.captured_at), + "checksum": snapshot.checksum, + "parser_version": snapshot.parser_version, + } + for snapshot in sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20] + ] return _render( request, "employee_detail.html", @@ -144,7 +144,8 @@ def employee_detail( @router.get("/runs", response_class=HTMLResponse) def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): require_admin(request, settings) - items = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all() + run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all() + items = [run_payload(run) for run in run_models] errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all() return _render(request, "runs.html", {"runs": items, "errors": errors}) diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 7f7b5ba..18b531d 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, datetime, time from math import ceil from typing import Any +from zoneinfo import ZoneInfo from sqlalchemy import Select, Text, and_, desc, func, or_, select from sqlalchemy.orm import Session @@ -30,6 +31,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]: "id": employee.id, "full_name": employee.full_name, "status": employee.status, + "status_display": _employee_status_display(employee.status), "canonical_url": employee.canonical_url, "positions": positions, "positions_text": "; ".join(positions), @@ -44,6 +46,9 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]: "first_seen_at": employee.first_seen_at.isoformat() if employee.first_seen_at else None, "last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None, "dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None, + "first_seen_display": format_admin_datetime(employee.first_seen_at), + "last_seen_display": format_admin_datetime(employee.last_seen_at), + "dismissed_display": format_admin_datetime(employee.dismissed_at), } @@ -154,8 +159,11 @@ def run_payload(run: CrawlRun | None) -> dict[str, Any] | None: "id": run.id, "source_url": run.source_url, "status": run.status, + "status_display": _run_status_display(run.status), "started_at": run.started_at.isoformat() if run.started_at else None, "finished_at": run.finished_at.isoformat() if run.finished_at else None, + "started_display": format_admin_datetime(run.started_at), + "finished_display": format_admin_datetime(run.finished_at), "found_count": run.found_count, "parsed_count": run.parsed_count, "new_count": run.new_count, @@ -167,6 +175,31 @@ def run_payload(run: CrawlRun | None) -> dict[str, Any] | None: } +def format_admin_datetime(value: Any) -> str: + if not value: + return "Не указано" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if not isinstance(value, datetime): + return str(value) + if value.tzinfo: + value = value.astimezone(ZoneInfo("Europe/Moscow")) + return value.strftime("%d.%m.%Y %H:%M") + + +def _employee_status_display(status: str | None) -> str: + labels = {"active": "Работает", "dismissed": "Уволен"} + return labels.get(status or "", status or "Не указано") + + +def _run_status_display(status: str | None) -> str: + labels = {"running": "Выполняется", "completed": "Завершен", "failed": "Ошибка"} + return labels.get(status or "", status or "Не указано") + + def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> int: total = 0 for section in sections: diff --git a/app/static/admin.js b/app/static/admin.js index 9e2165d..54ebf9c 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -79,7 +79,7 @@ const errors = document.querySelector("[data-progress-errors]"); const fill = document.querySelector("[data-progress-fill]"); const percent = document.querySelector("[data-progress-percent]"); - if (status) status.textContent = run.status; + if (status) status.textContent = run.status_display || run.status; if (processed) processed.textContent = run.processed_count; if (found) found.textContent = run.found_count; if (errors) errors.textContent = run.error_count; diff --git a/app/templates/base.html b/app/templates/base.html index 977dd0e..c9d4f77 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,12 +10,11 @@

MIEM Employees

diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 36060cc..cbb0c01 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,43 +1,43 @@ {% extends "base.html" %} -{% block title %}Dashboard · MIEM Employees{% endblock %} +{% block title %}Обзор · MIEM Employees{% endblock %} {% block content %}
-
Total
{{ counts.total }}
-
Active
{{ counts.active }}
-
New in last run
{{ counts.new_in_last_run }}
-
Dismissed
{{ counts.dismissed }}
+
Всего в базе
{{ counts.total }}
+
Работают
{{ counts.active }}
+
Новые за запуск
{{ counts.new_in_last_run }}
+
Уволены
{{ counts.dismissed }}
- Latest added + Последний добавленный {% if counts.latest_added %} {{ counts.latest_added.full_name or counts.latest_added.canonical_url }} {% else %} - No employees yet + Сотрудников пока нет {% endif %}
- Runs + Запуски {{ counts.runs }}
- Errors + Ошибки {{ counts.errors }}
-

Parsing progress

+

Прогресс парсинга

- +
{% set run = counts.current_running_run or latest_run %}
- {{ run.status if run else "idle" }} - {{ run.processed_count if run else 0 }} / {{ run.found_count if run else 0 }} processed - {{ run.error_count if run else 0 }} errors + {{ run.status_display if run else "Ожидание" }} + обработано: {{ run.processed_count if run else 0 }} / {{ run.found_count if run else 0 }} + ошибок: {{ run.error_count if run else 0 }}
@@ -46,12 +46,12 @@
-

Latest runs

+

Последние запуски

- + {% for run in runs %} - + {% endfor %}
IDStatusParsedErrorsStarted
IDСтатусОбработаноОшибкиСтарт
{{ run.id }}{{ run.status }}{{ run.parsed_count }}{{ run.error_count }}{{ run.started_at }}
{{ run.id }}{{ run.status_display }}{{ run.parsed_count }}{{ run.error_count }}{{ run.started_display }}
diff --git a/app/templates/directory.html b/app/templates/directory.html index 43f2a64..8570a12 100644 --- a/app/templates/directory.html +++ b/app/templates/directory.html @@ -1,65 +1,65 @@ {% extends "base.html" %} -{% block title %}Directory · MIEM Employees{% endblock %} +{% block title %}Сотрудники · MIEM Employees{% endblock %} {% block content %}
-

Directory

-

{{ page.total }} employees found

+

Сотрудники

+

Найдено: {{ page.total }}

- +
- + - - + + - +
- - - - + + + + - - - - - - - - + + + + + + + + {% for employee in page.employees %} - - + + @@ -67,13 +67,13 @@ - - - - + + + + {% else %} - + {% endfor %}
NameStatusPositionsHSE startФИОСтатусДолжностиГод начала EmailPhoneAddressPublicationsCoursesFirst seenLast seenDismissedProfileТелефонАдресПубликацииКурсыВпервые найденПоследний раз найденДата увольненияПрофиль
{{ employee.full_name or "No name" }}{{ employee.status }}{{ employee.full_name or "Без имени" }}{{ employee.status_display }} {{ employee.positions_text }} {{ employee.hse_start_year or "" }} {{ employee.email_text }}{{ employee.address or "" }} {{ employee.publications_count }} {{ employee.courses_count }}{{ employee.first_seen_at or "" }}{{ employee.last_seen_at or "" }}{{ employee.dismissed_at or "" }}Open{{ employee.first_seen_display }}{{ employee.last_seen_display }}{{ employee.dismissed_display }}Открыть
No employees match these filters.
По этим фильтрам сотрудники не найдены.
@@ -83,24 +83,24 @@ {% set prev_offset = filters.offset - filters.limit %} {% set next_offset = filters.offset + filters.limit %} {% if filters.offset > 0 %} - Previous + Назад {% endif %} - Page {{ page.page }}{% if page.pages %} of {{ page.pages }}{% endif %} + Страница {{ page.page }}{% if page.pages %} из {{ page.pages }}{% endif %} {% if next_offset < page.total %} - Next + Вперед {% endif %}
Год начала работы в ВШЭ
{{ employee_view.hse_start_year or "Не указано" }}
-
Profile type
{{ employee_view.profile_type or "Не указано" }}
-
Profile ID
{{ employee_view.profile_id or "Не указано" }}
-
First seen
{{ employee_view.first_seen_at or "Не указано" }}
-
Last seen
{{ employee_view.last_seen_at or "Не указано" }}
-
Dismissed at
{{ employee_view.dismissed_at or "Не указано" }}
-
Parser version
{{ employee_view.parser_version or "Не указано" }}
+
Тип профиля
{{ employee_view.profile_type or "Не указано" }}
+
ID профиля
{{ employee_view.profile_id or "Не указано" }}
+
Впервые найден
{{ employee_view.first_seen_display }}
+
Последний раз найден
{{ employee_view.last_seen_display }}
+
Дата увольнения
{{ employee_view.dismissed_display }}
@@ -188,12 +187,12 @@
-

Snapshots

+

Снапшоты

- + {% for snapshot in snapshots %} - + {% endfor %}
CapturedChecksumParser
ДатаChecksumПарсер
{{ snapshot.captured_at }}{{ snapshot.checksum }}{{ snapshot.parser_version }}
{{ snapshot.captured_display }}{{ snapshot.checksum }}{{ snapshot.parser_version }}
diff --git a/app/templates/employees.html b/app/templates/employees.html deleted file mode 100644 index 14ed033..0000000 --- a/app/templates/employees.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block title %}Employees · MIEM Employees{% endblock %} -{% block content %} -
-

Employees

-
- - - -
- - - - {% for employee in employees %} - - - - - - - {% endfor %} - -
NameStatusLast seenProfile
{{ employee.full_name or employee.profile_key }}{{ employee.status }}{{ employee.last_seen_at }}{{ employee.canonical_url }}
-
-{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 2db0146..8192c12 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -3,18 +3,18 @@ - Login · MIEM Employees + Вход · MIEM Employees
-

Admin login

+

Вход в админку

{% if error %}

{{ error }}

{% endif %}
- - - + + +
diff --git a/app/templates/runs.html b/app/templates/runs.html index 94f346f..bd86cd2 100644 --- a/app/templates/runs.html +++ b/app/templates/runs.html @@ -1,10 +1,10 @@ {% extends "base.html" %} -{% block title %}Runs · MIEM Employees{% endblock %} +{% block title %}Запуски · MIEM Employees{% endblock %} {% block content %}
-

Crawl runs

-
+

Запуски парсинга

+
{% set run = runs[0] if runs else none %} {% if run %} @@ -12,9 +12,9 @@ {% set percent = ((processed / run.found_count) * 100) | round(1) if run.found_count else 0 %}
- {{ run.status }} - {{ processed }} / {{ run.found_count }} processed - {{ run.error_count }} errors + {{ run.status_display }} + обработано: {{ processed }} / {{ run.found_count }} + ошибок: {{ run.error_count }}
@@ -24,9 +24,9 @@ {% else %}
- idle - 0 / 0 processed - 0 errors + Ожидание + обработано: 0 / 0 + ошибок: 0
@@ -35,18 +35,18 @@
{% endif %} - + {% for run in runs %} - + {% endfor %}
IDStatusFoundParsedNewErrorsDismissed
IDСтатусНайденоОбработаноНовыеОшибкиУволеныСтарт
{{ run.id }}{{ run.status }}{{ run.found_count }}{{ run.parsed_count }}{{ run.new_count }}{{ run.error_count }}{{ run.dismissed_count }}
{{ run.id }}{{ run.status_display }}{{ run.found_count }}{{ run.parsed_count }}{{ run.new_count }}{{ run.error_count }}{{ run.dismissed_count }}{{ run.started_display }}
-

Recent errors

+

Последние ошибки

- + {% for error in errors %} diff --git a/app/version.py b/app/version.py index 0cb62dc..d39182e 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.2.5" -FRONTEND_VERSION = "0.2.5" -BACKEND_VERSION = "0.2.5" +APP_VERSION = "0.2.6" +FRONTEND_VERSION = "0.2.6" +BACKEND_VERSION = "0.2.6" diff --git a/pyproject.toml b/pyproject.toml index 6886bdb..020c353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.1.0" +version = "0.2.6" description = "MIEM employees parser, admin API, and MCP server" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_admin_data.py b/tests/test_admin_data.py index ab41c95..fa555fe 100644 --- a/tests/test_admin_data.py +++ b/tests/test_admin_data.py @@ -4,12 +4,21 @@ from app.models import CrawlRun, Employee from app.services.admin_data import ( employee_detail_payload, employee_display_payload, + format_admin_datetime, list_employees_page, run_payload, stats_payload, ) +def test_format_admin_datetime_handles_datetime_string_and_none(): + value = datetime(2026, 4, 28, 17, 13, 34, tzinfo=timezone.utc) + + assert format_admin_datetime(value) == "28.04.2026 20:13" + assert format_admin_datetime("2026-04-28T17:13:34.448605+00:00") == "28.04.2026 20:13" + assert format_admin_datetime(None) == "Не указано" + + def test_employee_display_payload_extracts_common_fields(db_session): employee = Employee( profile_key="staff:person", @@ -32,9 +41,11 @@ def test_employee_display_payload_extracts_common_fields(db_session): payload = employee_display_payload(employee) assert payload["positions_text"] == "Professor" + assert payload["status_display"] == "Работает" assert payload["email_text"] == "person@hse.ru" assert payload["publications_count"] == 1 assert payload["courses_count"] == 1 + assert payload["first_seen_display"] != "Не указано" def test_employee_detail_payload_normalizes_human_readable_sections(db_session): @@ -180,3 +191,4 @@ def test_run_payload_calculates_progress(): assert payload["processed_count"] == 5 assert payload["progress_percent"] == 50.0 + assert payload["status_display"] == "Выполняется" diff --git a/tests/test_admin_templates.py b/tests/test_admin_templates.py new file mode 100644 index 0000000..9cba7b1 --- /dev/null +++ b/tests/test_admin_templates.py @@ -0,0 +1,32 @@ +from pathlib import Path + + +def test_base_navigation_is_russian_and_has_no_legacy_employees_link(): + template = Path("app/templates/base.html").read_text(encoding="utf-8") + + assert "Обзор" in template + assert "Сотрудники" in template + assert "Запуски" in template + assert "Выйти" in template + assert ">Employees<" not in template + assert "/admin/employees" not in template + + +def test_directory_template_is_russian_and_uses_display_dates(): + template = Path("app/templates/directory.html").read_text(encoding="utf-8") + + assert "Сотрудники" in template + assert "Колонки" in template + assert "Применить" in template + assert "Найдено:" in template + assert "employee.first_seen_display" in template + assert "employee.last_seen_display" in template + assert "employee.dismissed_display" in template + assert "Directory" not in template + assert "employees found" not in template + + +def test_admin_employees_route_redirects_to_directory(): + source = Path("app/admin.py").read_text(encoding="utf-8") + + assert 'RedirectResponse("/admin/directory", status_code=303)' in source diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index c885787..faf5f62 100644 --- a/tests/test_api_mcp.py +++ b/tests/test_api_mcp.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = client.get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "0.2.5" + assert response.json()["backend_version"] == "0.2.6" def test_mcp_requires_token_and_lists_tools(): diff --git a/tests/test_employee_detail_template.py b/tests/test_employee_detail_template.py index 55ee65e..4e7f08f 100644 --- a/tests/test_employee_detail_template.py +++ b/tests/test_employee_detail_template.py @@ -14,4 +14,15 @@ def test_employee_detail_template_is_human_readable(): assert "Основная информация" in template assert "Контакты" in template assert "Разделы профиля" in template - assert "Snapshots" in template + assert "Parser version" not in template + assert "First seen" not in template + assert "Last seen" not in template + assert "Dismissed at" not in template + assert "Profile type" not in template + assert "Profile ID" not in template + assert "Впервые найден" in template + assert "Последний раз найден" in template + assert "Дата увольнения" in template + assert "Тип профиля" in template + assert "ID профиля" in template + assert "Снапшоты" in template
RunProfileError
ЗапускПрофильОшибка
{{ error.crawl_run_id }}{{ error.profile_url }}{{ error.error_type }}: {{ error.message }}