From 755135d6bad09ea74cc3970766f889df5409ea97 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 29 Apr 2026 10:37:38 +0300 Subject: [PATCH] fix: make employee detail page human-readable --- README.md | 2 +- app/admin.py | 8 +- app/services/admin_data.py | 160 ++++++++++++++++++++ app/static/admin.css | 162 +++++++++++++++++++- app/templates/employee_detail.html | 199 +++++++++++++++++++++++-- app/version.py | 6 +- tests/test_admin_data.py | 65 +++++++- tests/test_api_mcp.py | 2 +- tests/test_employee_detail_template.py | 13 ++ 9 files changed, 595 insertions(+), 22 deletions(-) create mode 100644 tests/test_employee_detail_template.py diff --git a/README.md b/README.md index c1cda6a..a8c6631 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.1`. Админка всегда показывает версии backend и frontend в footer. +Версия сервиса: `0.2.2`. Админка всегда показывает версии backend и frontend в footer. diff --git a/app/admin.py b/app/admin.py index 1d1ae81..23df36e 100644 --- a/app/admin.py +++ b/app/admin.py @@ -8,7 +8,7 @@ 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 list_employees_page, run_payload, stats_payload +from app.services.admin_data import employee_detail_payload, 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 @@ -134,7 +134,11 @@ def employee_detail( if not employee: return RedirectResponse("/admin/employees", status_code=303) snapshots = sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20] - return _render(request, "employee_detail.html", {"employee": employee, "snapshots": snapshots}) + return _render( + request, + "employee_detail.html", + {"employee": employee, "employee_view": employee_detail_payload(employee), "snapshots": snapshots}, + ) @router.get("/runs", response_class=HTMLResponse) diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 133c4c9..aa0c3d4 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -46,6 +46,25 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]: } +def employee_detail_payload(employee: Employee) -> dict[str, Any]: + data = employee.current_data or {} + contacts = data.get("contacts") or {} + return { + **employee_display_payload(employee), + "profile_type": employee.profile_type or data.get("profile_type"), + "profile_id": employee.profile_id or data.get("profile_id"), + "parser_version": employee.parser_version or data.get("parser_version"), + "contacts": { + "emails": _clean_list(contacts.get("emails")), + "phones": _clean_list(contacts.get("phones")), + "address": contacts.get("address"), + "items": _normalize_contact_items(contacts.get("items")), + }, + "external_ids": _normalize_external_ids(data.get("external_ids")), + "sections": [_normalize_section(section) for section in data.get("sections") or []], + } + + def build_employee_query( *, status: str | None = None, @@ -157,3 +176,144 @@ def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> i elif section_type == "courses_by_year": total += len(section.get("courses") or []) return total + + +def _clean_list(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + return [str(value).strip() for value in values if str(value or "").strip()] + + +def _normalize_contact_items(items: Any) -> list[str]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if isinstance(item, dict): + value = item.get("raw") or item.get("value") or item.get("text") + else: + value = item + value = str(value or "").strip() + if value: + normalized.append(value) + return normalized + + +def _normalize_external_ids(items: Any) -> list[dict[str, str | None]]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if not isinstance(item, dict): + continue + system = str(item.get("system") or "").strip() + value = str(item.get("value") or "").strip() + url = str(item.get("url") or "").strip() + if system or value or url: + normalized.append({"system": system or "ID", "value": value or url, "url": url or None}) + return normalized + + +def _normalize_section(section: Any) -> dict[str, Any]: + if not isinstance(section, dict): + return {"title": "Раздел", "type": "generic", "paragraphs": [str(section)], "items": [], "links": []} + + section_type = section.get("type") or "generic" + paragraphs = _clean_list(section.get("paragraphs")) + items = _clean_list(section.get("items")) + raw_text = str(section.get("raw_text") or "").strip() + if not paragraphs and not items and raw_text: + paragraphs = [raw_text] + + return { + "title": section.get("title") or "Раздел", + "type": section_type, + "raw_text": raw_text, + "paragraphs": paragraphs, + "items": items, + "links": _normalize_links(section.get("links")), + "year_entries": _normalize_year_entries(section.get("year_entries")), + "publications": _normalize_publications(section.get("publications")), + "publications_count": section.get("publications_count"), + "academic_year": section.get("academic_year"), + "courses": _normalize_courses(section.get("courses")), + "table": _normalize_table(section.get("table")), + } + + +def _normalize_links(items: Any) -> list[dict[str, str | None]]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if not isinstance(item, dict): + continue + text = str(item.get("text") or item.get("url") or "").strip() + url = str(item.get("url") or "").strip() + if text and url: + normalized.append({"text": text, "url": url}) + return normalized + + +def _normalize_year_entries(items: Any) -> list[dict[str, Any]]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if not isinstance(item, dict): + continue + text = str(item.get("text") or "").strip() + if text: + normalized.append({"year": item.get("year"), "text": text, "links": _normalize_links(item.get("links"))}) + return normalized + + +def _normalize_publications(items: Any) -> list[dict[str, str | None]]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if not isinstance(item, dict): + text = str(item or "").strip() + if text: + normalized.append({"title": text, "text": text, "url": None}) + continue + title = str(item.get("title") or "").strip() + text = str(item.get("text") or title).strip() + url = str(item.get("url") or "").strip() + if title or text: + normalized.append({"title": title or text, "text": text or title, "url": url or None}) + return normalized + + +def _normalize_courses(items: Any) -> list[dict[str, str | None]]: + normalized = [] + if not isinstance(items, list): + return normalized + for item in items: + if not isinstance(item, dict): + title = str(item or "").strip() + if title: + normalized.append({"title": title, "url": None}) + continue + title = str(item.get("title") or "").strip() + url = str(item.get("url") or "").strip() + if title or url: + normalized.append({"title": title or url, "url": url or None}) + return normalized + + +def _normalize_table(table: Any) -> dict[str, Any] | None: + if not isinstance(table, dict): + return None + headers = _clean_list(table.get("headers")) + rows = [] + for row in table.get("rows") or []: + if not isinstance(row, dict): + continue + cells = _clean_list(row.get("cells")) + if cells: + rows.append({"cells": cells, "link_url": row.get("link_url")}) + if not headers and not rows: + return None + return {"headers": headers, "rows": rows} diff --git a/app/static/admin.css b/app/static/admin.css index 455693c..6a668c5 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -152,6 +152,165 @@ white-space: pre-wrap; } +.employee-card { + display: grid; + gap: 18px; +} + +.employee-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 22px; + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; +} + +.employee-card__identity { + display: grid; + gap: 10px; +} + +.employee-card__title { + margin: 0; + font-size: 24px; +} + +.employee-card__section { + padding: 20px; + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; +} + +.employee-card__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + margin: 0; +} + +.employee-card__meta-item { + min-width: 0; +} + +.employee-card__meta-item--wide { + grid-column: 1 / -1; +} + +.employee-card__meta-label { + margin-bottom: 5px; + color: #6b7280; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.employee-card__meta-value { + margin: 0; + color: #1f2937; + line-height: 1.45; +} + +.employee-card__list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 18px; +} + +.employee-card__list-item { + line-height: 1.45; +} + +.employee-card__sections { + display: grid; + gap: 14px; +} + +.employee-section { + padding: 16px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.employee-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.employee-section__title { + margin: 0; + font-size: 17px; +} + +.employee-section__type { + flex: 0 0 auto; + padding: 3px 8px; + color: #475569; + background: #e2e8f0; + border-radius: 999px; + font-size: 12px; +} + +.employee-section__note { + margin: 0 0 10px; + color: #4b5563; + font-weight: 700; +} + +.employee-section__text { + margin: 0 0 10px; + line-height: 1.55; +} + +.employee-section__table-wrap { + overflow-x: auto; +} + +.employee-section__table { + width: 100%; + border-collapse: collapse; + background: #ffffff; +} + +.employee-section__head, +.employee-section__cell { + padding: 10px; + border-bottom: 1px solid #e5e7eb; + text-align: left; + vertical-align: top; +} + +.employee-section__head { + color: #374151; + background: #f3f4f6; + font-size: 13px; +} + +.employee-section__links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.employee-section__link { + padding: 5px 9px; + color: #0f766e; + background: #ccfbf1; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} + .stats-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -399,7 +558,8 @@ } .progress-panel__header, - .directory__header { + .directory__header, + .employee-card__header { align-items: stretch; flex-direction: column; } diff --git a/app/templates/employee_detail.html b/app/templates/employee_detail.html index 220bb12..7eeae72 100644 --- a/app/templates/employee_detail.html +++ b/app/templates/employee_detail.html @@ -1,19 +1,192 @@ {% extends "base.html" %} -{% block title %}{{ employee.full_name }} · MIEM Employees{% endblock %} +{% block title %}{{ employee_view.full_name }} · MIEM Employees{% endblock %} {% block content %} -
-

{{ employee.full_name or employee.profile_key }}

-

{{ employee.status }}

-

{{ employee.canonical_url }}

-

Tabs

- -

Current data

-
{{ employee.current_data | tojson(indent=2) }}
+
+
+
+

{{ employee_view.full_name or employee.profile_key }}

+ {{ employee_view.status }} +
+ {{ employee_view.canonical_url }} +
+ +
+

Основная информация

+
+
+
Должности
+
+ {% if employee_view.positions %} +
    + {% for position in employee_view.positions %} +
  • {{ position }}
  • + {% endfor %} +
+ {% else %} + Не указано + {% 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 "Не указано" }}
+
+
+ +
+

Контакты

+
+
+
Email
+
+ {% if employee_view.contacts.emails %} +
    + {% for email in employee_view.contacts.emails %} +
  • {{ email }}
  • + {% endfor %} +
+ {% else %} + Не указано + {% endif %} +
+
+
+
Телефоны
+
{{ employee_view.contacts.phones | join(", ") if employee_view.contacts.phones else "Не указано" }}
+
+
+
Адрес
+
{{ employee_view.contacts.address or "Не указано" }}
+
+ {% if employee_view.contacts.items %} +
+
Прочее
+
+
    + {% for item in employee_view.contacts.items %} +
  • {{ item }}
  • + {% endfor %} +
+
+
+ {% endif %} +
+
+ + {% if employee_view.external_ids %} +
+

Внешние идентификаторы

+
    + {% for external_id in employee_view.external_ids %} +
  • + {{ external_id.system }}: + {% if external_id.url %} + {{ external_id.value }} + {% else %} + {{ external_id.value }} + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + +
+

Разделы профиля

+ {% if employee_view.sections %} +
+ {% for section in employee_view.sections %} +
+
+

{{ section.title }}

+ {{ section.type }} +
+ + {% if section.type == "year_blocks" and section.year_entries %} +
    + {% for entry in section.year_entries %} +
  • {% if entry.year %}{{ entry.year }}: {% endif %}{{ entry.text }}
  • + {% endfor %} +
+ {% elif section.type == "publications" and section.publications %} + {% if section.publications_count %}

Всего: {{ section.publications_count }}

{% endif %} +
    + {% for publication in section.publications %} +
  • + {% if publication.url %} + {{ publication.title }} + {% else %} + {{ publication.title }} + {% endif %} + {% if publication.text and publication.text != publication.title %}
    {{ publication.text }}
    {% endif %} +
  • + {% endfor %} +
+ {% elif section.type == "courses_by_year" and section.courses %} + {% if section.academic_year %}

Учебный год: {{ section.academic_year }}

{% endif %} +
    + {% for course in section.courses %} +
  • + {% if course.url %} + {{ course.title }} + {% else %} + {{ course.title }} + {% endif %} +
  • + {% endfor %} +
+ {% elif section.type == "table" and section.table %} +
+ + {% if section.table.headers %} + {% for header in section.table.headers %}{% endfor %} + {% endif %} + + {% for row in section.table.rows %} + + {% for cell in row.cells %} + + {% endfor %} + + {% endfor %} + +
{{ header }}
{{ cell }}
+
+ {% else %} + {% if section.paragraphs %} + {% for paragraph in section.paragraphs %} +

{{ paragraph }}

+ {% endfor %} + {% endif %} + {% if section.items %} +
    + {% for item in section.items %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} + + {% if section.links and section.type not in ["courses_by_year"] %} + + {% endif %} +
+ {% endfor %} +
+ {% else %} +

Разделы профиля не найдены.

+ {% endif %} +
+

Snapshots

diff --git a/app/version.py b/app/version.py index 14b371c..d5c9b77 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.2.1" -FRONTEND_VERSION = "0.2.1" -BACKEND_VERSION = "0.2.1" +APP_VERSION = "0.2.2" +FRONTEND_VERSION = "0.2.2" +BACKEND_VERSION = "0.2.2" diff --git a/tests/test_admin_data.py b/tests/test_admin_data.py index f9502d2..03deb49 100644 --- a/tests/test_admin_data.py +++ b/tests/test_admin_data.py @@ -1,7 +1,13 @@ from datetime import datetime, timezone from app.models import CrawlRun, Employee -from app.services.admin_data import employee_display_payload, list_employees_page, run_payload, stats_payload +from app.services.admin_data import ( + employee_detail_payload, + employee_display_payload, + list_employees_page, + run_payload, + stats_payload, +) def test_employee_display_payload_extracts_common_fields(db_session): @@ -31,6 +37,63 @@ def test_employee_display_payload_extracts_common_fields(db_session): assert payload["courses_count"] == 1 +def test_employee_detail_payload_normalizes_human_readable_sections(db_session): + employee = Employee( + profile_key="staff:person", + profile_type="staff", + profile_id="person", + canonical_url="https://www.hse.ru/staff/person", + full_name="Person Name", + status="active", + first_seen_at=datetime.now(timezone.utc), + last_seen_at=datetime.now(timezone.utc), + current_data={ + "positions": ["Professor"], + "hse_start_year": 2024, + "contacts": { + "emails": ["person@hse.ru"], + "phones": ["+79990000000"], + "address": "Moscow", + "items": [{"raw": "consultation hours"}], + }, + "external_ids": [{"system": "ORCID", "value": "0000", "url": "https://orcid.org/0000"}], + "sections": [ + { + "title": "Education", + "type": "year_blocks", + "year_entries": [{"year": 2020, "text": "Master degree"}], + }, + { + "title": "Publications", + "type": "publications", + "publications": [{"title": "Paper", "text": "Paper details", "url": "https://example.test/paper"}], + }, + { + "title": "Courses", + "type": "courses_by_year", + "academic_year": "2025/2026", + "courses": [{"title": "Course", "url": "https://example.test/course"}], + }, + { + "title": "Fallback", + "type": "generic", + "raw_text": "Fallback text", + }, + ], + }, + ) + + payload = employee_detail_payload(employee) + + assert payload["contacts"]["emails"] == ["person@hse.ru"] + assert payload["contacts"]["items"] == ["consultation hours"] + assert payload["external_ids"][0]["system"] == "ORCID" + assert payload["sections"][0]["year_entries"][0]["text"] == "Master degree" + assert payload["sections"][1]["publications"][0]["title"] == "Paper" + assert payload["sections"][2]["courses"][0]["title"] == "Course" + assert payload["sections"][3]["paragraphs"] == ["Fallback text"] + + def test_list_employees_page_filters_sorts_and_paginates(db_session): db_session.add( Employee( diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index 1bc041d..72ebd21 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.1" + assert response.json()["backend_version"] == "0.2.2" def test_mcp_requires_token_and_lists_tools(): diff --git a/tests/test_employee_detail_template.py b/tests/test_employee_detail_template.py new file mode 100644 index 0000000..c7443cf --- /dev/null +++ b/tests/test_employee_detail_template.py @@ -0,0 +1,13 @@ +from pathlib import Path + + +def test_employee_detail_template_is_human_readable(): + template = Path("app/templates/employee_detail.html").read_text(encoding="utf-8") + + assert "Current data" not in template + assert "
Tabs<" not in template
+    assert "Основная информация" in template
+    assert "Контакты" in template
+    assert "Разделы профиля" in template
+    assert "Snapshots" in template
-- 
2.49.1