diff --git a/CHANGELOG.md b/CHANGELOG.md index 26eab12..8892619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.5.1 + +- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию. + +## 1.5.0 + +- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`. + ## 0.18.1 - Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение. diff --git a/app/admin.py b/app/admin.py index 01c1cf8..3ad1ade 100644 --- a/app/admin.py +++ b/app/admin.py @@ -113,6 +113,7 @@ def directory( started_to: str | None = None, has_email: str | None = None, has_academic_degree: str | None = None, + pps: bool = False, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -134,6 +135,7 @@ def directory( started_to=parsed_started_to, has_email=parsed_has_email, has_academic_degree=parsed_has_academic_degree, + pps=pps, sort=sort, direction=direction, limit=limit, @@ -154,6 +156,7 @@ def directory( "started_to": started_to or "", "has_email": has_email or "", "has_academic_degree": has_academic_degree or "", + "pps": pps, "sort": sort, "direction": direction, "limit": page["limit"], diff --git a/app/api.py b/app/api.py index 6bde8b0..c5f5b45 100644 --- a/app/api.py +++ b/app/api.py @@ -29,6 +29,7 @@ def list_employees( started_to: date | None = None, has_email: bool | None = None, has_academic_degree: bool | None = None, + pps: bool = False, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -45,6 +46,7 @@ def list_employees( started_to=started_to, has_email=has_email, has_academic_degree=has_academic_degree, + pps=pps, sort=sort, direction=direction, limit=limit, diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 228b7de..2cf4d14 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -116,6 +116,7 @@ def list_employees_page( started_to: date | None = None, has_email: bool | None = None, has_academic_degree: bool | None = None, + pps: bool = False, sort: str = "full_name", direction: str = "asc", limit: int = 50, @@ -131,12 +132,23 @@ def list_employees_page( has_email=has_email, has_academic_degree=has_academic_degree, ) - total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 - if offset >= total: - offset = 0 sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) order = desc(sort_column) if direction == "desc" else sort_column - employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all() + ordered_stmt = base_stmt.order_by(order) + if pps: + # Match decoded position strings consistently in PostgreSQL and SQLite. + employees = [employee for employee in db.scalars(ordered_stmt).all() + if any("кафедра" in position.casefold() or "департамент" in position.casefold() + for position in _clean_list(_as_dict(employee.current_data).get("positions")))] + total = len(employees) + if offset >= total: + offset = 0 + employees = employees[offset:offset + limit] + else: + total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 + if offset >= total: + offset = 0 + employees = db.scalars(ordered_stmt.limit(limit).offset(offset)).all() return { "employees": [employee_display_payload(employee) for employee in employees], "total": total, diff --git a/app/static/admin.css b/app/static/admin.css index 2a69e02..db1d93f 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -281,6 +281,20 @@ border-radius: 8px; } +.employee-card__news-toggle { + min-height: 44px; + align-content: center; + cursor: pointer; +} + +.employee-card__news-toggle .employee-section__title { + display: inline; +} + +.employee-card__section--news[open] .employee-card__news-toggle { + margin-bottom: 12px; +} + .employee-card__meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); @@ -598,6 +612,15 @@ border-radius: 6px; } +.directory__checkbox { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + font-size: 14px; + cursor: pointer; +} + .directory__filter-actions { display: flex; align-items: end; diff --git a/app/templates/directory.html b/app/templates/directory.html index 30c9f45..90a64fb 100644 --- a/app/templates/directory.html +++ b/app/templates/directory.html @@ -35,6 +35,7 @@ +
Период обнаружения @@ -112,7 +113,7 @@ {% else %} Сотрудники не найдены - {% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.started_from or filters.started_to %} + {% if filters.q or filters.status or filters.has_email or filters.has_academic_degree or filters.pps or filters.started_from or filters.started_to %} Попробуйте изменить запрос или сбросить фильтры. Сбросить фильтры {% else %} diff --git a/app/templates/employee_detail.html b/app/templates/employee_detail.html index 0004878..161a326 100644 --- a/app/templates/employee_detail.html +++ b/app/templates/employee_detail.html @@ -106,8 +106,8 @@ {% endif %} {% if employee_view.news_links %} -
-

В новостях

+
+

В новостях

    {% for news in employee_view.news_links %}
  • @@ -121,14 +121,15 @@
  • {% endfor %}
-
+ {% endif %}

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

- {% if employee_view.sections %} + {% set profile_sections = employee_view.sections | rejectattr('type', 'equalto', 'news') | list %} + {% if profile_sections %}
- {% for section in employee_view.sections %} + {% for section in profile_sections %}

{{ section.title }}

diff --git a/app/version.py b/app/version.py index e4e7483..6a65aac 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.4.2" -FRONTEND_VERSION = "1.4.2" -BACKEND_VERSION = "1.4.2" +APP_VERSION = "1.5.1" +FRONTEND_VERSION = "1.5.1" +BACKEND_VERSION = "1.5.1" diff --git a/pyproject.toml b/pyproject.toml index b204836..4d7c7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.4.2" +version = "1.5.1" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_api.py b/tests/test_api.py index 6e42b7f..f97c474 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"] == "1.4.2" + assert response.json()["backend_version"] == "1.5.1" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index e4df701..4d4b2c8 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -230,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.4.2 · Frontend 1.4.2" in response.text + assert "Backend 1.5.1 · Frontend 1.5.1" in response.text assert "Вход в систему" in response.text assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403) assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text diff --git a/tests/test_employee_detail_template.py b/tests/test_employee_detail_template.py index 1c69087..771145d 100644 --- a/tests/test_employee_detail_template.py +++ b/tests/test_employee_detail_template.py @@ -1,5 +1,13 @@ from pathlib import Path +import pytest +from bs4 import BeautifulSoup +from jinja2 import Environment, FileSystemLoader +from starlette.requests import Request + +from app.models import Employee, EmployeeNewsLink +from app.services.admin_data import employee_detail_payload + def test_employee_detail_template_is_human_readable(): template = Path("app/templates/employee_detail.html").read_text(encoding="utf-8") @@ -33,3 +41,41 @@ def test_employee_detail_template_is_human_readable(): assert "Обновить данные" in template assert 'action="/admin/employees/{{ employee.id }}/refresh"' in template assert "Снапшоты" in template + + +@pytest.mark.parametrize("news_source", [None, "snapshot", "stored"]) +@pytest.mark.parametrize("has_other_section", [False, True]) +def test_employee_news_render_once_in_closed_disclosure(db_session, news_source, has_other_section): + sections = [{"title": "Биография", "type": "generic", "paragraphs": ["Текст биографии"]}] if has_other_section else [] + if news_source: + sections.append({"title": "В новостях", "type": "news", + "news_links": [{"title": "Новость из профиля", "url": "https://example.test/snapshot"}], + "paragraphs": ["Дублирующий текст новостей"]}) + employee = Employee(profile_key="staff:news", canonical_url="https://example.test/profile", + full_name="Тестовый сотрудник", status="active", current_data={"sections": sections}) + db_session.add(employee) + db_session.flush() + if news_source == "stored": + db_session.add(EmployeeNewsLink(employee_id=employee.id, title="Сохранённая новость", + url="https://example.test/stored", summary="Описание новости", source_hash="a" * 64)) + db_session.flush() + template = Environment(loader=FileSystemLoader("app/templates"), autoescape=True).get_template("employee_detail.html") + rendered = template.render(employee=employee, employee_view=employee_detail_payload(employee), snapshots=[], + request=Request({"type": "http", "scheme": "http", "headers": [], + "path": f"/admin/employees/{employee.id}"})) + soup = BeautifulSoup(rendered, "html.parser") + assert not any(badge.get_text(strip=True) == "news" for badge in soup.select(".employee-section__type")) + assert "Дублирующий текст новостей" not in rendered + assert ("Текст биографии" in rendered) == has_other_section + assert ("Разделы профиля не найдены." in rendered) == (not has_other_section) + news = soup.select("details.employee-card__section--news") + assert len(news) == (1 if news_source else 0) + if news_source: + assert not news[0].has_attr("open") + assert news[0].summary.h3.get_text(strip=True) == "В новостях" + expected_url = f"https://example.test/{'stored' if news_source == 'stored' else 'snapshot'}" + assert [link["href"] for link in news[0].select("a")] == [expected_url] + assert len(soup.select(f'a[href="{expected_url}"]')) == 1 + if news_source == "stored": + assert "Описание новости" in news[0].get_text() + assert "Новость из профиля" not in rendered diff --git a/tests/test_pps_filter.py b/tests/test_pps_filter.py new file mode 100644 index 0000000..640b8d8 --- /dev/null +++ b/tests/test_pps_filter.py @@ -0,0 +1,55 @@ +from fastapi.testclient import TestClient + +from app.config import Settings, get_settings +from app.db import get_db +from app.main import app +from app.models import Employee +from app.security import SESSION_COOKIE, sign_session +from app.services.admin_data import list_employees_page + + +def test_pps_filter_matches_positions_before_pagination_and_preserves_state(db_session): + settings = Settings(admin_username="admin", admin_password="password", session_secret="test-pps") + db = db_session + data = [ + {"positions": ["Доцент: Кафедра физики"]}, + {"positions": ["Инженер", "ДЕПАРТАМЕНТ информатики"]}, + {"positions": ["кафедра математики"]}, + {"positions": ["Инженер"], "address": "Кафедра физики"}, + {"positions": []}, {}, None, + ] + for index, current_data in enumerate(data): + db.add(Employee(profile_key=f"pps:{index}", canonical_url=f"https://example.test/{index}", + full_name=f"Person {index}", status="dismissed" if index == 2 else "active", + current_data=current_data)) + db.commit() + assert list_employees_page(db)["total"] == 7 + assert list_employees_page(db, pps=False)["total"] == 7 + page = list_employees_page(db, pps=True, status="active", offset=1) + assert page["total"] == 2 + assert [item["full_name"] for item in page["employees"]] == ["Person 1"] + assert list_employees_page(db, pps=True)["total"] == 3 + assert list_employees_page(db, pps=True, q="Person 3")["total"] == 0 + + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[get_settings] = lambda: settings + try: + client = TestClient(app) + client.cookies.set(SESSION_COOKIE, sign_session("admin", settings)) + response = client.get("/api/employees?pps=true&status=active") + assert response.status_code == 200 + assert response.json()["total"] == 2 + response = client.get("/admin/directory?pps=true&offset=1&limit=25") + assert response.status_code == 200 + assert 'value="true" checked' in response.text + assert "Person 0" not in response.text + assert "Person 1" in response.text + assert "pps=true" in response.text.split('>Назад')[0].rsplit('href="', 1)[1] + empty = client.get("/admin/directory?pps=true&q=Person+3") + assert "Сбросить фильтры" in empty.text + assert "Запустите парсинг" not in empty.text + reset = client.get("/admin/directory") + assert 'value="true" checked' not in reset.text + assert "Показаны 1–7 из 7" in reset.text + finally: + app.dependency_overrides.clear()