diff --git a/README.md b/README.md
index 9680293..32b60b1 100644
--- a/README.md
+++ b/README.md
@@ -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 предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
diff --git a/app/services/council.py b/app/services/council.py
index 3ecdd0a..72f7106 100644
--- a/app/services/council.py
+++ b/app/services/council.py
@@ -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,
diff --git a/app/templates/council.html b/app/templates/council.html
index 24d3904..c4cd2fc 100644
--- a/app/templates/council.html
+++ b/app/templates/council.html
@@ -5,7 +5,7 @@
@@ -83,8 +83,8 @@
{% endif %}
{% else %}
-
{% if q %}Сотрудники не найдены{% else %}Публикаций пока нет{% endif %}
-
{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}В доступных источниках нет публикаций, которые можно однозначно связать с сотрудниками.{% endif %}
+
{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}
+
{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}
{% if q %}
Сбросить поиск{% endif %}
{% endif %}
diff --git a/app/version.py b/app/version.py
index a0a4892..6ebf289 100644
--- a/app/version.py
+++ b/app/version.py
@@ -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"
diff --git a/pyproject.toml b/pyproject.toml
index 3073864..f081c04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = [
diff --git a/tests/browser_council.py b/tests/browser_council.py
index b4defee..a4d7e7c 100644
--- a/tests/browser_council.py
+++ b/tests/browser_council.py
@@ -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")
diff --git a/tests/test_api.py b/tests/test_api.py
index 42b16d7..551af66 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.0"
+ assert response.json()["backend_version"] == "0.11.1"
def test_api_employees_and_stats_require_admin_session():
diff --git a/tests/test_council.py b/tests/test_council.py
index 9839dc3..7dd89b3 100644
--- a/tests/test_council.py
+++ b/tests/test_council.py
@@ -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='', 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 "<script>" in response.text and "