fix: restore doctoral degree requirement for council candidates
CI / test (pull_request) Successful in 9m13s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Has been skipped

This commit is contained in:
agent committed 2026-10-07 11:58:18 +03:00
1 parent c6c6866235
commit ed9d0b48c9
8 files changed
+40 -21

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
## Кандидаты в диссертационный совет
Страница `/admin/council` доступна без авторизации. Она показывает всех сотрудников с хотя бы одной публикацией в CSV или таблице `employee_publications`, независимо от степени и количества работ; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО и страницы по 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 предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
+3
View File
@@ -19,6 +19,7 @@ from app.services.academic_degrees import academic_degrees
EXPORT = Path(__file__).resolve().parents[1] / "data" / "council_articles.csv"
DOCTOR = re.compile(r"^доктор\b.+\bнаук$", re.IGNORECASE)
DOI = re.compile(r"10\.\d{4,9}/\S+", re.IGNORECASE)
TYPES = {"ARTICLE": "Статья", "BOOK": "Книга", "CHAPTER": "Глава книги",
"BOOK_CHAPTER": "Глава книги", "REPORT": "Доклад", "PATENT": "Патент",
@@ -167,6 +168,8 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
degrees = academic_degrees(data)
except (AttributeError, TypeError):
degrees = []
if not any(DOCTOR.fullmatch(degree) for degree in degrees):
continue
candidates.append({"name": employee.full_name or "ФИО не указано", "degree": "; ".join(degrees) or "Учёная степень не указана",
"count": len(articles), "csv_count": sum(a["csv_qualifies"] for a in articles),
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
+3 -3
View File
@@ -5,7 +5,7 @@
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title" id="council-title">Диссертационный совет</h2>
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке все сотрудники с хотя бы одной публикацией в файле CSV или базе miem-workers, независимо от степени и количества работ.</p>
<p class="council__intro">Публикации сотрудников для проверки состава совета. В списке сотрудники с докторской степенью в профиле и хотя бы одной публикацией в файле CSV или базе miem-workers. Минимальный порог количества работ не применяется.</p>
</div>
</header>
@@ -83,8 +83,8 @@
{% endif %}
{% else %}
<div class="council__message">
<h4>{% if q %}Сотрудники не найдены{% else %}Публикаций пока нет{% endif %}</h4>
<p>{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}В доступных источниках нет публикаций, которые можно однозначно связать с сотрудниками.{% endif %}</p>
<h4>{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}</h4>
<p>{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}</p>
{% if q %}<a class="admin__link" href="/admin/council">Сбросить поиск</a>{% endif %}
</div>
{% endif %}
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.11.0"
FRONTEND_VERSION = "0.11.0"
BACKEND_VERSION = "0.11.0"
APP_VERSION = "0.11.1"
FRONTEND_VERSION = "0.11.1"
BACKEND_VERSION = "0.11.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.11.0"
version = "0.11.1"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+8 -1
View File
@@ -31,12 +31,18 @@ def main():
person = Employee(profile_key=f"test:{number}", full_name=f"Сотрудник {number:02d}",
canonical_url="https://www.hse.ru/staff/test",
current_data={"sections": [{"title": "Учёные степени",
"items": ["Кандидат технических наук"]}]})
"items": ["Доктор технических наук"]}]})
db.add(person)
db.flush()
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}"))
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 []}]})
db.add(person)
db.flush()
db.add(EmployeePublication(employee_id=person.id, title="Не включать в совет", year=2024, source_hash=name))
db.commit()
def database():
@@ -65,6 +71,7 @@ def main():
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://miem.test/admin/council")
expect(page.get_by_role("status")).to_have_text("Показаны 1–25 из 28")
assert "Без степени" not in page.content() and "Кандидат наук" not in page.content()
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
page.get_by_role("link", name="Далее").click()
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
+1 -1
View File
@@ -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.0"
assert response.json()["backend_version"] == "0.11.1"
def test_api_employees_and_stats_require_admin_session():
+20 -11
View File
@@ -28,7 +28,7 @@ def export(monkeypatch, tmp_path):
council._articles_by_author.cache_clear()
def employee(db, name="Иванов Иван Иванович", degree=None, status="active", key="doctor"):
def employee(db, name="Иванов Иван Иванович", degree="Доктор технических наук", status="active", key="doctor"):
person = Employee(profile_key=key, canonical_url="https://www.hse.ru/staff/test", full_name=name,
status=status, current_data={"contacts": {"emails": ["private@example.org"]},
"sections": [{"title": "Учёные степени", "items": [degree] if degree else []}]})
@@ -76,20 +76,28 @@ def test_council_union_counts_provenance_and_types(db_session, export):
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
def test_every_employee_with_a_publication_no_degree_or_count_threshold(db_session, export):
export([row(author="Кандидат", title="Single qualifying"),
row(author="Без степени", title="Not Q1/Q2", **{"Квартиль scopus": "Q3"}),
def test_only_doctors_with_a_publication_no_count_threshold(db_session, export):
export([row(author="Доктор", title="Single qualifying"),
row(author="Без степени", title="No degree"),
row(author="Кандидат", title="Candidate work"),
row(author="PhD", title="PhD work"),
row(author="Две степени", title="Mixed degrees", **{"Квартиль scopus": "Q3"}),
row(author="Уволен", title="Old", year="2010")])
employee(db_session, "Кандидат", "Кандидат технических наук", key="candidate")
employee(db_session, "Без степени", key="none")
employee(db_session, "Доктор", key="csv-only")
for name, degree in [("Без степени", None), ("Кандидат", "Кандидат технических наук"), ("PhD", "Ph.D.")]:
person = employee(db_session, name, degree, key=name)
publication(db_session, person)
employee(db_session, "Две степени", "Кандидат технических наук; Доктор физико-математических наук", key="mixed")
employee(db_session, "Уволен", status="dismissed", key="dismissed")
only_db = employee(db_session, "Только БД", key="database")
publication(db_session, only_db, kind="PATENT")
employee(db_session, "Без публикаций", key="empty")
db_session.commit()
items = council.council_candidates(db_session, date(2026, 9, 30))["candidates"]
assert {c["name"] for c in items} == {"Кандидат", "Без степени", "Уволен", "Только БД"}
assert next(c for c in items if c["name"] == "Без степени")["csv_count"] == 0
assert {c["name"] for c in items} == {"Доктор", "Две степени", "Уволен", "Только БД"}
assert next(c for c in items if c["name"] == "Доктор")["csv_count"] == 1
assert next(c for c in items if c["name"] == "Только БД")["db_count"] == 1
assert next(c for c in items if c["name"] == "Две степени")["csv_count"] == 0
assert next(c for c in items if c["name"] == "Уволен")["dismissed"]
@@ -144,7 +152,7 @@ def test_search_and_pagination_include_all_workers(db_session, export):
def test_public_page_data_minimization_and_safe_links(db_session, export):
export([row(title='<script>alert("csv")</script>', doi="javascript:bad")])
person = employee(db_session, degree="Кандидат технических наук")
person = employee(db_session)
person.canonical_url = "javascript:bad"
publication(db_session, person, url="javascript:alert(1)", doi="javascript:bad", kind="BOOK")
db_session.commit()
@@ -153,13 +161,14 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
client = TestClient(app)
response = client.get("/admin/council", follow_redirects=False)
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text and "Кандидат технических наук" in response.text
assert "Иванов Иван Иванович" in response.text and "Доктор технических наук" in response.text
assert "Минимальный порог количества работ не применяется" in response.text
assert "CSV по критериям" in response.text and "В БД" in response.text and "Всего без дублей" in response.text
assert "Книга" in response.text and "Тип не указан" in response.text
assert "&lt;script&gt;" in response.text and "<script>" not in response.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 0.11.0 · Frontend 0.11.0" in response.text
assert "Backend 0.11.1 · Frontend 0.11.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