Фильтр ППС и сворачиваемый раздел новостей сотрудников #73
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.1
|
||||
|
||||
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
|
||||
|
||||
## 0.18.1
|
||||
|
||||
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select></label>
|
||||
<label class="directory__checkbox"><input type="checkbox" name="pps" value="true" {% if filters.pps %}checked{% endif %}> ППС</label>
|
||||
</fieldset>
|
||||
<fieldset class="directory__filter-group">
|
||||
<legend class="directory__filter-legend">Период обнаружения</legend>
|
||||
@@ -112,7 +113,7 @@
|
||||
{% else %}
|
||||
<tr><td class="directory-table__empty" colspan="15">
|
||||
<strong>Сотрудники не найдены</strong>
|
||||
{% 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 %}
|
||||
<span>Попробуйте изменить запрос или сбросить фильтры.</span>
|
||||
<a class="admin__link" href="/admin/directory">Сбросить фильтры</a>
|
||||
{% else %}
|
||||
|
||||
@@ -106,8 +106,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if employee_view.news_links %}
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">В новостях</h3>
|
||||
<details class="employee-card__section employee-card__section--news">
|
||||
<summary class="employee-card__news-toggle"><h3 class="employee-section__title">В новостях</h3></summary>
|
||||
<ul class="employee-card__list">
|
||||
{% for news in employee_view.news_links %}
|
||||
<li class="employee-card__list-item">
|
||||
@@ -121,14 +121,15 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<section class="employee-card__section">
|
||||
<h3 class="employee-section__title">Разделы профиля</h3>
|
||||
{% if employee_view.sections %}
|
||||
{% set profile_sections = employee_view.sections | rejectattr('type', 'equalto', 'news') | list %}
|
||||
{% if profile_sections %}
|
||||
<div class="employee-card__sections">
|
||||
{% for section in employee_view.sections %}
|
||||
{% for section in profile_sections %}
|
||||
<article class="employee-section">
|
||||
<div class="employee-section__header">
|
||||
<h4 class="employee-section__title">{{ section.title }}</h4>
|
||||
|
||||
+3
-3
@@ -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"
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
+1
-1
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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('>Назад</a>')[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()
|
||||
Reference in new issue
Block a user