From 32e26ccdd2b691ca6f04f49bbde0554df2f287af Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 7 Oct 2026 16:50:29 +0300 Subject: [PATCH] feat: add council employee sorting by name and publication counts --- README.md | 2 +- app/admin.py | 5 +++-- app/services/council.py | 11 ++++++++--- app/templates/council.html | 23 +++++++++++++++++----- app/version.py | 6 +++--- pyproject.toml | 2 +- tests/browser_council.py | 34 +++++++++++++++++++++++++++++--- tests/test_api.py | 2 +- tests/test_council.py | 40 +++++++++++++++++++++++++++++++++++++- 9 files changed, 105 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 32b60b1..6d36b4c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан ## Кандидаты в диссертационный совет -Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО и страницы по 25 сотрудников. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями. +Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями. CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки. diff --git a/app/admin.py b/app/admin.py index 6282104..2692ac9 100644 --- a/app/admin.py +++ b/app/admin.py @@ -134,9 +134,10 @@ def directory( @router.get("/council", response_class=HTMLResponse) -def council(request: Request, q: str = "", offset: int = 0, db: Session = Depends(get_db)): +def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_count", direction: str = "desc", + db: Session = Depends(get_db)): try: - result = council_candidates(db, q=q, offset=offset) + result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction) except (SQLAlchemyError, OSError, ValueError, KeyError): db.rollback() return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503) diff --git a/app/services/council.py b/app/services/council.py index 72f7106..b41ecca 100644 --- a/app/services/council.py +++ b/app/services/council.py @@ -149,7 +149,8 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict] return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold())) -def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0) -> dict: +def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0, + sort: str = "csv_count", direction: str = "desc") -> dict: today = today or datetime.now(ZoneInfo("Europe/Moscow")).date() start, end = period(today) employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all() @@ -175,7 +176,11 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs "db_count": sum(a["in_db"] for a in articles), "articles": articles, "profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous, "dismissed": employee.status == "dismissed"}) - candidates.sort(key=lambda item: (-item["csv_count"], -item["count"], item["name"].casefold())) + sort = sort if sort in {"full_name", "csv_count", "db_count", "count"} else "csv_count" + direction = direction if direction in {"asc", "desc"} else "desc" + candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc") + if sort != "full_name": + candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc") total = len(candidates) query = _name(q) candidates = [item for item in candidates if query in _name(item["name"])] @@ -183,4 +188,4 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs limit = 25 offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0 return {"candidates": candidates[offset:offset + limit], "start": start, "end": end, "total": total, - "matched": matched, "q": q, "offset": offset, "limit": limit} + "matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction} diff --git a/app/templates/council.html b/app/templates/council.html index c4cd2fc..da2a614 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -26,8 +26,21 @@ - - {% if q %}Сбросить поиск{% endif %} + + + + {% if q %}Сбросить поиск{% endif %}

Сотрудники

@@ -76,16 +89,16 @@
{% if matched > limit %} {% endif %} {% else %}

{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}

{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}

- {% if q %}Сбросить поиск{% endif %} + {% if q %}Сбросить поиск{% endif %}
{% endif %} {% endif %} diff --git a/app/version.py b/app/version.py index 6ebf289..879d8b0 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.11.1" -FRONTEND_VERSION = "0.11.1" -BACKEND_VERSION = "0.11.1" +APP_VERSION = "0.12.0" +FRONTEND_VERSION = "0.12.0" +BACKEND_VERSION = "0.12.0" diff --git a/pyproject.toml b/pyproject.toml index f081c04..6beebfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "0.11.1" +version = "0.12.0" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/browser_council.py b/tests/browser_council.py index a4d7e7c..5991992 100644 --- a/tests/browser_council.py +++ b/tests/browser_council.py @@ -37,6 +37,9 @@ def main(): db.add(EmployeePublication(employee_id=person.id, title="Исследование методов передачи данных в фотонных интегральных схемах", year=2024, publication_type="ARTICLE", publication_id=f"{number}", url=f"https://publications.hse.ru/view/{number}", source_hash=f"test:{number}")) + if number == 27: + db.add(EmployeePublication(employee_id=person.id, title="Дополнительная статья", year=2024, + publication_type="ARTICLE", source_hash="test:extra")) for name, degree in [("Без степени", None), ("Кандидат наук", "Кандидат технических наук")]: person = Employee(profile_key=name, full_name=name, canonical_url="https://www.hse.ru/staff/test", current_data={"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]}) @@ -50,7 +53,10 @@ def main(): yield db original = council._articles_by_author - council._articles_by_author = lambda: {} + council._articles_by_author = lambda: {"сотрудник 26": [ + {"title": f"Публикация из CSV {number}", "year": 2024, "doi": f"10.1234/test-{number}", + "types": [], "quartiles": ["Scopus Q1"], "origins": ["Файл CSV"], "rows": [number + 2], + "in_csv": True, "in_db": False, "url": None} for number in range(2)]} app.dependency_overrides[get_db] = database client = TestClient(app) output = Path(".impeccable/review") @@ -83,20 +89,42 @@ def main(): expect(page.get_by_text("Q1/Q2, К1 и RSCI не подтверждены доступными данными").first).to_be_visible() assert page.locator(".council__candidate").first.evaluate("e => e.open") page.screenshot(path=str(output / "desktop.png"), full_page=True) + page.screenshot(path=str(output / "sorting-desktop.png"), full_page=False) for width in (1024, 768, 700, 390, 320): page.set_viewport_size({"width": width, "height": 844}) assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), width assert summary.evaluate("e => e.scrollWidth <= e.clientWidth"), width page.set_viewport_size({"width": 390, "height": 844}) page.screenshot(path=str(output / "mobile.png"), full_page=True) + page.screenshot(path=str(output / "sorting-mobile.png"), full_page=False) + for sort, direction, first in [("full_name", "asc", "Сотрудник 00"), + ("full_name", "desc", "Сотрудник 27"), + ("db_count", "desc", "Сотрудник 27"), + ("count", "desc", "Сотрудник 26"), + ("csv_count", "desc", "Сотрудник 26")]: + page.get_by_label("Сортировать по").select_option(sort) + page.get_by_label("Направление").select_option(direction) + page.get_by_role("button", name="Применить", exact=True).click() + expect(page.locator(".council__candidate-identity strong").first).to_have_text(first) + page.get_by_label("Сортировать по").select_option("full_name") + page.get_by_label("Направление").select_option("desc") + page.get_by_role("button", name="Применить", exact=True).click() + page.get_by_role("link", name="Далее").click() + expect(page.get_by_label("Сортировать по")).to_have_value("full_name") + expect(page.get_by_label("Направление")).to_have_value("desc") + expect(page.locator(".council__candidate-identity strong").first).to_have_text("Сотрудник 02") page.get_by_label("Поиск по ФИО").fill("Сотрудник 27") - page.get_by_role("button", name="Найти", exact=True).click() + page.get_by_role("button", name="Применить", exact=True).click() expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1 · Всего в списке: 28") + expect(page.get_by_label("Сортировать по")).to_have_value("full_name") + expect(page.get_by_label("Направление")).to_have_value("desc") page.get_by_label("Поиск по ФИО").fill("Нет совпадений") - page.get_by_role("button", name="Найти", exact=True).click() + page.get_by_role("button", name="Применить", exact=True).click() expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible() page.get_by_role("link", name="Сбросить поиск").first.click() expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28") + expect(page.get_by_label("Сортировать по")).to_have_value("full_name") + expect(page.get_by_label("Направление")).to_have_value("desc") assert not errors, errors browser.close() finally: diff --git a/tests/test_api.py b/tests/test_api.py index 551af66..6396d4b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "0.11.1" + assert response.json()["backend_version"] == "0.12.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 7dd89b3..6441b46 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -148,6 +148,44 @@ def test_search_and_pagination_include_all_workers(db_session, export): assert council.council_candidates(db_session, q="петров 27")["matched"] == 1 assert council.council_candidates(db_session, offset=-1)["offset"] == 0 assert council.council_candidates(db_session, offset=999)["offset"] == 25 + descending = council.council_candidates(db_session, sort="full_name", direction="desc") + last_descending = council.council_candidates(db_session, sort="full_name", direction="desc", offset=25) + assert descending["candidates"][0]["name"] == "Петров 27" + assert [c["name"] for c in last_descending["candidates"]] == ["Петров 02", "Петров 01", "Петров 00"] + + +def test_sort_by_name_and_each_unique_publication_count(db_session, export): + export([row(author="Анна", title=f"CSV {number}", doi=f"10.1234/csv-{number}") for number in range(3)] + + [row(author="Борис", title="CSV Boris", doi="10.1234/boris")]) + anna = employee(db_session, "Анна", key="anna") + publication(db_session, anna, title="CSV 0", doi="https://doi.org/10.1234/csv-0") + for name, count in [("Борис", 2), ("Вера", 4)]: + person = employee(db_session, name, key=name) + for number in range(count): + publication(db_session, person, title=f"DB {name} {number}") + db_session.commit() + expected = { + "full_name": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]}, + "csv_count": {"asc": ["Вера", "Борис", "Анна"], "desc": ["Анна", "Борис", "Вера"]}, + "db_count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Борис", "Анна"]}, + "count": {"asc": ["Анна", "Борис", "Вера"], "desc": ["Вера", "Анна", "Борис"]}, + } + for sort, directions in expected.items(): + for direction, names in directions.items(): + result = council.council_candidates(db_session, sort=sort, direction=direction) + assert [c["name"] for c in result["candidates"]] == names + invalid = council.council_candidates(db_session, sort="unknown", direction="unknown") + assert (invalid["sort"], invalid["direction"]) == ("csv_count", "desc") + assert [c["name"] for c in invalid["candidates"]] == expected["csv_count"]["desc"] + app.dependency_overrides[get_db] = lambda: db_session + try: + response = TestClient(app).get("/admin/council?sort=full_name&direction=desc") + assert response.status_code == 200 + assert response.text.index("Вера") < response.text.index("Борис") < response.text.index("Анна") + assert '