diff --git a/app/admin.py b/app/admin.py index b313708..8cacf37 100644 --- a/app/admin.py +++ b/app/admin.py @@ -78,8 +78,9 @@ def directory( status: str | None = None, q: str | None = None, started_from: str | None = None, - started_to: str | None = None, - has_email: str | None = None, + started_to: str | None = None, + has_email: str | None = None, + has_academic_degree: str | None = None, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -90,14 +91,16 @@ def directory( require_admin(request, settings) parsed_started_from = _parse_date(started_from) parsed_started_to = _parse_date(started_to) - parsed_has_email = None if has_email in (None, "") else has_email == "true" + parsed_has_email = None if has_email in (None, "") else has_email == "true" + parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true" page = list_employees_page( db, status=status, q=q, started_from=parsed_started_from, started_to=parsed_started_to, - has_email=parsed_has_email, + has_email=parsed_has_email, + has_academic_degree=parsed_has_academic_degree, sort=sort, direction=direction, limit=limit, @@ -113,7 +116,8 @@ def directory( "q": q or "", "started_from": started_from or "", "started_to": started_to or "", - "has_email": has_email or "", + "has_email": has_email or "", + "has_academic_degree": has_academic_degree or "", "sort": sort, "direction": direction, "limit": page["limit"], diff --git a/app/api.py b/app/api.py index 37b65fa..e5c920c 100644 --- a/app/api.py +++ b/app/api.py @@ -28,6 +28,7 @@ def list_employees( started_from: date | None = None, started_to: date | None = None, has_email: bool | None = None, + has_academic_degree: bool | None = None, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -43,6 +44,7 @@ def list_employees( started_from=started_from, started_to=started_to, has_email=has_email, + has_academic_degree=has_academic_degree, sort=sort, direction=direction, limit=limit, diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 0149117..15d68f3 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from datetime import date, datetime, time from math import ceil from typing import Any @@ -19,6 +20,8 @@ EMPLOYEE_SORTS = { "hse_start_year": Employee.current_data["hse_start_year"].as_integer(), } +_ACADEMIC_DEGREE_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE) + def employee_display_payload(employee: Employee) -> dict[str, Any]: data = _as_dict(employee.current_data) @@ -28,6 +31,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]: positions = _clean_list(data.get("positions")) emails = _clean_list(contacts.get("emails")) phones = _clean_list(contacts.get("phones")) + academic_degrees = _academic_degrees(sections) return { "id": employee.id, "full_name": employee.full_name, @@ -42,6 +46,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]: "phones": phones, "phone_text": ", ".join(phones), "address": contacts.get("address"), + "academic_degree_text": "; ".join(academic_degrees), "publications_count": _count_section_items(sections, "publications"), "courses_count": _count_section_items(sections, "courses_by_year"), "news_count": len(stored_news_links) or _count_section_items(sections, "news"), @@ -81,6 +86,7 @@ def build_employee_query( started_from: date | None = None, started_to: date | None = None, has_email: bool | None = None, + has_academic_degree: bool | None = None, ) -> Select[tuple[Employee]]: stmt = select(Employee) filters = [] @@ -97,6 +103,15 @@ def build_employee_query( filters.append(Employee.current_data.cast(Text).ilike("%@%")) elif has_email is False: filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%"))) + if has_academic_degree is not None: + data_text = Employee.current_data.cast(Text) + degree_condition = or_( + and_(_json_text_contains(data_text, "кандидат"), _json_text_contains(data_text, "наук")), + and_(_json_text_contains(data_text, "доктор"), _json_text_contains(data_text, "наук")), + _json_text_contains(data_text, "phd"), + _json_text_contains(data_text, "ph.d."), + ) + filters.append(degree_condition if has_academic_degree else or_(Employee.current_data.is_(None), ~degree_condition)) if filters: stmt = stmt.where(and_(*filters)) return stmt @@ -110,6 +125,7 @@ def list_employees_page( started_from: date | None = None, started_to: date | None = None, has_email: bool | None = None, + has_academic_degree: bool | None = None, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -123,6 +139,7 @@ def list_employees_page( started_from=started_from, started_to=started_to, has_email=has_email, + has_academic_degree=has_academic_degree, ) total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) @@ -214,6 +231,36 @@ def format_admin_datetime(value: Any) -> str: return value.strftime("%d.%m.%Y %H:%M") +def _academic_degrees(sections: list[Any]) -> list[str]: + degrees = [] + for section in sections: + section_data = _as_dict(section) + title = str(section_data.get("title") or "") + if not re.search(r"уч[её]н.*степен|academic degree", title, re.IGNORECASE): + continue + values = [ + *(_as_dict(entry).get("text") for entry in _as_list(section_data.get("year_entries"))), + *_clean_list(section_data.get("paragraphs")), + *_clean_list(section_data.get("items")), + section_data.get("raw_text"), + ] + for value in values: + text = str(value or "").strip() + if text and _ACADEMIC_DEGREE_PATTERN.search(text) and text not in degrees: + degrees.append(text) + return degrees + + +def _json_text_contains(data_text: Any, value: str) -> Any: + escaped = value.encode("unicode_escape").decode("ascii") + escaped_capitalized = value.capitalize().encode("unicode_escape").decode("ascii") + return or_( + data_text.ilike(f"%{value}%"), + data_text.ilike(f"%{escaped}%"), + data_text.ilike(f"%{escaped_capitalized}%"), + ) + + def _employee_status_display(status: str | None) -> str: labels = {"active": "Работает", "dismissed": "Уволен"} return labels.get(status or "", status or "Не указано") diff --git a/app/static/admin.js b/app/static/admin.js index 9b539b8..dc5b1d6 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -5,6 +5,7 @@ "positions", "hse_start_year", "email", + "academic_degree", "last_seen_at", "dismissed_at", "profile", diff --git a/app/templates/directory.html b/app/templates/directory.html index 2b4cd51..8436bf9 100644 --- a/app/templates/directory.html +++ b/app/templates/directory.html @@ -22,6 +22,11 @@ Есть email Нет email + + Любая учёная степень + Есть учёная степень + Нет учёной степени + @@ -53,6 +58,7 @@ Email Телефон Адрес + Учёная степень Публикации Курсы Новости @@ -72,6 +78,7 @@ {{ employee.email_text }} {{ employee.phone_text }} {{ employee.address or "" }} + {{ employee.academic_degree_text }} {{ employee.publications_count }} {{ employee.courses_count }} {{ employee.news_count }} @@ -81,7 +88,7 @@ Открыть {% else %} - По этим фильтрам сотрудники не найдены. + По этим фильтрам сотрудники не найдены. {% endfor %} @@ -108,7 +115,7 @@ Закрыть - {% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_count", "Новости"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %} + {% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("academic_degree", "Учёная степень"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_count", "Новости"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %} {{ label }} {% endfor %} diff --git a/app/version.py b/app/version.py index dca7a84..c28ec84 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.7.1" -FRONTEND_VERSION = "0.7.1" -BACKEND_VERSION = "0.7.1" +APP_VERSION = "0.7.2" +FRONTEND_VERSION = "0.7.2" +BACKEND_VERSION = "0.7.2" diff --git a/pyproject.toml b/pyproject.toml index 7d37163..6178c09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.7.1" +version = "0.7.2" 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 9bf2018..2bad2dc 100644 --- a/tests/test_admin_data.py +++ b/tests/test_admin_data.py @@ -51,6 +51,45 @@ def test_employee_display_payload_extracts_common_fields(db_session): assert payload["first_seen_display"] != "Не указано" +def test_list_employees_page_filters_and_displays_academic_degrees(db_session): + db_session.add_all( + [ + Employee( + profile_key="staff:degree", + canonical_url="https://www.hse.ru/staff/degree", + full_name="Doctor", + status="active", + first_seen_at=datetime.now(timezone.utc), + last_seen_at=datetime.now(timezone.utc), + current_data={ + "sections": [ + { + "title": "Образование и учёные степени", + "year_entries": [{"year": 2020, "text": "Доктор технических наук"}], + } + ] + }, + ), + Employee( + profile_key="staff:no-degree", + canonical_url="https://www.hse.ru/staff/no-degree", + full_name="Master", + status="active", + first_seen_at=datetime.now(timezone.utc), + last_seen_at=datetime.now(timezone.utc), + current_data={"sections": [{"title": "Образование", "items": ["Магистратура"]}]}, + ), + ] + ) + db_session.commit() + + page = list_employees_page(db_session, has_academic_degree=True) + + assert page["total"] == 1 + assert page["employees"][0]["full_name"] == "Doctor" + assert page["employees"][0]["academic_degree_text"] == "Доктор технических наук" + + def test_employee_detail_payload_normalizes_human_readable_sections(db_session): employee = Employee( profile_key="staff:person", diff --git a/tests/test_admin_templates.py b/tests/test_admin_templates.py index eef4b49..e039a85 100644 --- a/tests/test_admin_templates.py +++ b/tests/test_admin_templates.py @@ -22,7 +22,9 @@ def test_directory_template_is_russian_and_uses_display_dates(): assert "На странице: {{ value }}" in template assert "{% for value in [25, 50, 100] %}" in template assert "Найдено:" in template - assert "Новости" in template + assert "Новости" in template + assert "Есть учёная степень" in template + assert 'data-column="academic_degree"' in template assert "employee.news_count" in template assert "employee.first_seen_display" in template assert "employee.last_seen_display" in template