fix: simplify council assessment for technical faculty
This commit is contained in:
1 parent
a3f4a5b321
commit
ec9a03fcf2
14 files changed
+65
-73
No files matched your search
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Страница совета и XLSX настроены на технические науки и пятилетний период относительно текущей даты. Убраны дата ходатайства, сведения о монографиях и связанные причины проверки.
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Ручные основания больше не возвращают книги и другие недопущенные виды в предварительные счётчики совета. Дополнительные виды учитываются только при подтверждённой разрешающей политике и согласованном виде материала.
|
||||
|
||||
@@ -90,7 +90,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Страница `/admin/council` доступна без авторизации. Показывает докторов, кандидатов наук и PhD, включая сотрудников без доступных публикаций; уволенные отмечены отдельно. Высшая степень выбирается по всей истории: доктор выше кандидата/PhD, кандидат и PhD имеют одинаковый уровень. Неподтверждённого доктора нельзя заменить подтверждённым кандидатом. Звание профессора или доцента не заменяет степень.
|
||||
|
||||
Форма принимает дату ходатайства и отрасль предполагаемого включения; отрасль не выводится автоматически из степени или специальности. Пункт 5: для доктора порог по отрасли — 7, 8, 9 либо 11 работ, для части отраслей нужна одна монография. Пункт 6: кандидат/PhD — 20 работ; самостоятельного К2 недостаточно. Период — пять лет перед указанной датой. Без даты показывается явно предварительный период относительно текущей даты Москвы. Некорректные дату, отрасль и специальность отклоняет HTTP 422.
|
||||
Страница и XLSX настроены на технические науки: пункт 5 — 9 публикаций для доктора; пункт 6 — 20 для кандидата/PhD, самостоятельного К2 недостаточно. Период — пять лет относительно текущей даты Москвы. Дата ходатайства и сведения о монографиях не отображаются и не препятствуют итогу по техническим наукам. Некорректную специальность отклоняет HTTP 422.
|
||||
|
||||
Отдельно показаны предварительные Q1/Q2/К1/К2/RSCI, их объединение ВСЕГО и подтверждённое число, порог, монографии, пункт и причины проверки. Одна работа учитывается один раз, основания сохраняют исходные записи. Без подтверждения высшей степени, даты, отрасли и достаточного числа проверенных работ результат — «Требует проверки». Недостаток записей не доказывает отсутствие работ; соответствие публикационным критериям не означает автоматического включения в совет.
|
||||
|
||||
@@ -155,7 +155,7 @@ node --check app/static/admin.js
|
||||
|
||||
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.16.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.16.2`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
## Разовый импорт публикаций
|
||||
|
||||
|
||||
+2
-4
@@ -136,11 +136,10 @@ def directory(
|
||||
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
petition_date: str = "", branch: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
petition_date=petition_date, branch=branch)
|
||||
branch="технические")
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
@@ -151,11 +150,10 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
|
||||
|
||||
@router.get("/council/export.xlsx")
|
||||
def export_council(request: Request, q: str = "", sort: str = "total_count", direction: str = "desc", specialty: str = "",
|
||||
petition_date: str = "", branch: str = "",
|
||||
db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = council_candidates(db, q=q, sort=sort, direction=direction, paginate=False, specialty=specialty,
|
||||
petition_date=petition_date, branch=branch)
|
||||
branch="технические")
|
||||
content = council_xlsx(result)
|
||||
except CouncilInputError as exc:
|
||||
return _render(request, "council.html", {"error": True, "public_page": True, "input_error": str(exc)}, status_code=422)
|
||||
|
||||
+5
-12
@@ -20,7 +20,7 @@ from app.services.academic_degrees import academic_degrees
|
||||
from app.services.council_source import _doi, _journal_names, _name, _quartiles, load_reference
|
||||
from app.services.council_catalog import catalog, publication_catalog
|
||||
from app.services.council_assessment import (
|
||||
BRANCHES, assess_candidate, degree_level, evidence, iso_date, publication_proofs,
|
||||
BRANCHES, assess_candidate, degree_level, evidence, publication_proofs,
|
||||
publication_type_allowed, sourced,
|
||||
)
|
||||
|
||||
@@ -226,18 +226,11 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
|
||||
|
||||
def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
|
||||
sort: str = "total_count", direction: str = "desc", paginate: bool = True, specialty: str = "",
|
||||
petition_date: date | str | None = None, branch: str = "") -> dict:
|
||||
if isinstance(petition_date, str):
|
||||
try:
|
||||
petition_date = iso_date(petition_date) if petition_date else None
|
||||
except ValueError as exc:
|
||||
raise CouncilInputError("Некорректная дата ходатайства; ожидается ГГГГ-ММ-ДД") from exc
|
||||
if petition_date and petition_date.year <= 5:
|
||||
raise CouncilInputError("Дата ходатайства не позволяет построить пятилетний период")
|
||||
branch: str = "технические") -> dict:
|
||||
if branch and branch not in BRANCHES:
|
||||
raise CouncilInputError("Неизвестная отрасль науки")
|
||||
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
|
||||
start, end = period(petition_date or today)
|
||||
start, end = period(today)
|
||||
specialties = _k1_specialties()["specialties"]
|
||||
if specialty and specialty not in {s["code"] for s in specialties}:
|
||||
raise CouncilInputError("Неизвестная специальность ВАК")
|
||||
@@ -287,7 +280,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
key = {"К1": "k1", "К2": "k2", "Q1": "q1", "Q2": "q2", "RSCI": "rsci"}.get(basis.get("basis"))
|
||||
if key:
|
||||
article[key] = True
|
||||
assessment = assess_candidate(degrees, articles, employee_evidence, petition_date, branch, specialty, start, end)
|
||||
assessment = assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end)
|
||||
specialty_count = sum(specialty in a["specialty_codes"] for a in articles) if specialty else 0
|
||||
for article in articles:
|
||||
article["matches_specialty"] = bool(specialty and specialty in article["specialty_codes"])
|
||||
@@ -315,7 +308,7 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
|
||||
return {"candidates": candidates[offset:offset + limit] if paginate else candidates,
|
||||
"start": start, "end": end, "total": total,
|
||||
"specialties": specialties, "specialty": specialty, "specialty_name": specialty_name,
|
||||
"petition_date": petition_date, "branch": branch, "branches": BRANCHES,
|
||||
"branch": branch,
|
||||
"catalog_metadata": catalog()["metadata"], "catalog_issues": catalog()["issues"],
|
||||
"category_metadata": [{"file": path.name, **{key: data[key] for key in ("source", "source_sha256", "source_rows")}}
|
||||
for path in (K1_JOURNALS, K2_JOURNALS)
|
||||
|
||||
@@ -230,7 +230,7 @@ def assess_article(article, proofs, start, end, level, branch, specialty, policy
|
||||
article["thematic_status"] = "Результаты рассмотрения статьи расходятся — требуется проверка"
|
||||
|
||||
|
||||
def assess_candidate(degrees, articles, employee_evidence, petition_date, branch, specialty, start, end):
|
||||
def assess_candidate(degrees, articles, employee_evidence, branch, specialty, start, end):
|
||||
proofs = [r for r in employee_evidence.get("degrees", []) if isinstance(r.get("degree"), str) and sourced(r)]
|
||||
history = list(dict.fromkeys([*degrees, *(r["degree"] for r in proofs)]))
|
||||
level = max((degree_level(d) for d in history), default=0)
|
||||
@@ -251,8 +251,6 @@ def assess_candidate(degrees, articles, employee_evidence, petition_date, branch
|
||||
reasons.append("Учёная степень для пунктов 5–6 не установлена")
|
||||
elif not degree_confirmed:
|
||||
reasons.append("Высшая текущая степень документально не подтверждена")
|
||||
if not petition_date:
|
||||
reasons.append("Дата ходатайства не задана: показан предварительный период")
|
||||
if not branch:
|
||||
reasons.append("Отрасль предполагаемого включения не задана")
|
||||
if not articles:
|
||||
|
||||
@@ -6,7 +6,6 @@ from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from app.services.council_assessment import BRANCHES
|
||||
|
||||
|
||||
def _append(sheet, values):
|
||||
@@ -29,8 +28,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
|
||||
_append(employees, ["№", "ФИО", "Учёная степень", "Q1", "Q2", "К1", "ВСЕГО", "Страница ВШЭ", "Все публикации",
|
||||
"Профиль ВШЭ", "Примечание", "Специальности ВАК", "Работ по выбранной специальности",
|
||||
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Дата ходатайства", "Минимум",
|
||||
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Монографии подтверждены", "Минимум монографий", "Итог", "Причины проверки"])
|
||||
"Текущая степень", "Степень подтверждена", "Пункт", "Отрасль оценки", "Минимум",
|
||||
"Подтверждено", "К2 предварительно", "RSCI предварительно", "Итог", "Причины проверки"])
|
||||
_append(publications, ["№ сотрудника", "ФИО", "Название публикации", "Год", "Тип материала", "Источники",
|
||||
"Критерии", "Q1", "Q2", "К1", "В ВСЕГО", "Страница ВШЭ", "DOI", "Ссылка на сайте", "Строки CSV", "Специальности ВАК", "По выбранной специальности",
|
||||
"К2", "RSCI", "Подтверждённый зачёт", "Дата публикации", "Причины проверки", "Подтверждённые основания",
|
||||
@@ -44,8 +43,8 @@ def council_xlsx(result: dict) -> bytes:
|
||||
candidate["profile_url"], note, specialties_text(specialty_codes),
|
||||
candidate["specialty_count"] if result["specialty"] else None,
|
||||
candidate["selected_degree"], "Да" if candidate["degree_confirmed"] else "Нет", candidate["clause"],
|
||||
result["branch"], result["petition_date"], candidate["minimum"], candidate["confirmed_count"],
|
||||
candidate["k2_count"], candidate["rsci_count"], candidate["monograph_count"], candidate["monograph_minimum"],
|
||||
result["branch"], candidate["minimum"], candidate["confirmed_count"],
|
||||
candidate["k2_count"], candidate["rsci_count"],
|
||||
candidate["assessment_status"], "; ".join(candidate["assessment_reasons"])])
|
||||
for article in candidate["articles"]:
|
||||
_append(publications, [number, candidate["name"], article["title"], article["year"], article["type_label"],
|
||||
@@ -61,11 +60,11 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"; ".join(t for r in article["catalog_records"] for t in r["thematic"]), article["thematic_status"]])
|
||||
_append(parameters, ["Параметр", "Значение"])
|
||||
for values in [
|
||||
["Дата ходатайства", result["petition_date"] or "Не задана — предварительный период"],
|
||||
["Отрасль предполагаемого включения", result["branch"] or "Не выбрана"],
|
||||
["Отрасль оценки", result["branch"]],
|
||||
["Период оценки", "Пять лет относительно текущей даты Москвы"],
|
||||
["Статус счётчиков", "Q1/Q2/К1/К2/RSCI и ВСЕГО предварительные; подтверждённый зачёт показан отдельно. К2 самостоятельно допустим только для пункта 5."],
|
||||
["Предел заключения", "Только публикационные критерии; не автоматическое включение в совет. Отсутствие доказательств не означает отсутствие публикаций."],
|
||||
["Границы и виды", "Спорные граничные даты, допустимость иных видов и период монографий требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
|
||||
["Границы и виды", "Спорные граничные даты и допустимость иных видов требуют подтверждения по источнику. Без него соответствующие работы не подтверждают минимум."],
|
||||
["Специальность ВАК", f"{result['specialty']}. — {result['specialty_name']}" if result["specialty"] else "Все специальности"],
|
||||
["Подбор по специальности", "Хотя бы одна публикация за пять лет в релевантном журнале К1 по Перечню ВАК на 15.09.2026; содержание статьи и исторические специальности не проверяются."],
|
||||
["Начало периода", result["start"]], ["Конец периода", result["end"]], ["Поиск по ФИО", result["q"]],
|
||||
@@ -86,8 +85,7 @@ def council_xlsx(result: dict) -> bytes:
|
||||
"Страница ВШЭ учитывает сохранённые публикации, включая импортированные из CSV; источник указан на листе Публикации."],
|
||||
]:
|
||||
_append(parameters, values)
|
||||
for branch, (minimum, monographs) in BRANCHES.items():
|
||||
_append(parameters, [f"Пункт 5 · {branch}", f"Публикаций: {minimum}; обязательных монографий: {monographs}"])
|
||||
_append(parameters, ["Пункт 5 · технические", "9 публикаций; К1/К2/RSCI/Q1/Q2"])
|
||||
_append(parameters, ["Пункт 6 · кандидат/PhD", "20 публикаций; К1/RSCI/Q1/Q2; одного К2 недостаточно"])
|
||||
_append(parameters, ["Квартильные каталоги", "Ограниченные подборки, метрики 2025. Текущая индексация не проверена; это не означает отсутствие индексации. Тематика журнала не подтверждает статью."])
|
||||
for meta in result["catalog_metadata"]:
|
||||
@@ -96,7 +94,7 @@ def council_xlsx(result: dict) -> bytes:
|
||||
_append(parameters, ["Проверка каталога", issue])
|
||||
for meta in result["category_metadata"]:
|
||||
_append(parameters, [meta["file"], f"Источник {meta['source']}; SHA-256 {meta['source_sha256']}; проверено строк {meta['source_rows']}"])
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 18, 12, 16, 16, 16, 20, 20, 50, 70]),
|
||||
for sheet, widths in [(employees, [7, 34, 40, 10, 10, 10, 12, 20, 22, 45, 55, 80, 28, 40, 20, 10, 28, 12, 16, 16, 16, 50, 70]),
|
||||
(publications, [16, 34, 65, 12, 40, 45, 65, 10, 10, 10, 12, 20, 45, 45, 22, 80, 28, 10, 10, 20, 18, 70, 75, 60, 90, 75, 60]),
|
||||
(parameters, [32, 95])]:
|
||||
sheet.freeze_panes = "A2"
|
||||
|
||||
@@ -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">Проверка публикационных критериев пунктов 5–6 для докторов, кандидатов наук и PhD. Показаны обнаруженные степени, предварительные сведения и отдельно подтверждённые работы. Соответствие этим критериям не означает автоматического включения в совет.</p>
|
||||
<p class="council__intro">Проверка публикационных критериев пунктов 5–6 по техническим наукам для докторов, кандидатов наук и PhD. Показаны обнаруженные степени, предварительные сведения и отдельно подтверждённые работы. Соответствие этим критериям не означает автоматического включения в совет.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -21,22 +21,13 @@
|
||||
<details class="council__notice">
|
||||
<summary><strong>Как читать счётчики и критерии</strong></summary>
|
||||
<p>Предварительные Q1/Q2 включают отметки CSV, применимые снимки каталогов и отдельные подтверждения. Предварительные К1/К2 включают совпадения журнала со справочником ВАК. ВСЕГО — объединение Q1/Q2/К1/RSCI и К2 для доктора наук. Работа учитывается один раз. Подтверждённое число требует самостоятельных доказательств применимости основания к этой работе.</p>
|
||||
<p>Период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}{% if not petition_date %} предварительный, относительно текущей даты{% endif %}. Подтверждённый итог требует документального подтверждения высшей степени, даты ходатайства, отрасли и работ с проверенными типом, периодом и допустимым основанием. Пограничные даты и годы, неизвестные типы и исторические категории требуют проверки. Предварительные числа сами по себе не подтверждают соответствие порогу. К2 допустим самостоятельно только для доктора наук; РИНЦ не заменяет RSCI.</p>
|
||||
<p>Период {{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }} относительно текущей даты. Подтверждённый итог требует документального подтверждения высшей степени и работ с проверенными типом, периодом и допустимым основанием. Пограничные даты и годы, неизвестные типы и исторические категории требуют проверки. Предварительные числа сами по себе не подтверждают соответствие порогу. К2 допустим самостоятельно только для доктора наук; РИНЦ не заменяет RSCI.</p>
|
||||
<p>Q1/Q2-каталоги — ограниченные тематические подборки со снимком метрик 2025 года. Они не подтверждают другие годы и текущую индексацию. Данные основаны на открытых копиях таблиц Scopus/JCR. Аналитическая связь журнала со специальностью не подтверждает содержание конкретной статьи. Порог и тематическое рассмотрение показываются отдельно.</p>
|
||||
<p>«В БД» и «Все публикации» показывают количество работ за все годы, независимо от категорий. Общий перечень объединяет CSV и БД без дублей. Наличие публикаций не подтверждает соответствие всем требованиям совета.</p>
|
||||
<p>При выборе специальности показаны сотрудники с хотя бы одной учитываемой за пять лет публикацией в журнале К1, которому эта специальность присвоена в «ВАК Перечень.pdf» на 15.09.2026. Это соответствие по журналу; содержание статьи и исторический перечень специальностей на дату публикации не проверяются.</p>
|
||||
</details>
|
||||
|
||||
<form class="council__search" method="get" action="/admin/council" role="search">
|
||||
<label class="directory__field" for="council-date">Дата ходатайства
|
||||
<input class="directory__input" id="council-date" name="petition_date" type="date" value="{{ petition_date.isoformat() if petition_date else '' }}">
|
||||
</label>
|
||||
<label class="directory__field" for="council-branch">Отрасль предполагаемого включения
|
||||
<select class="directory__input" id="council-branch" name="branch">
|
||||
<option value="">Не выбрана — требуется проверка</option>
|
||||
{% for value in branches %}<option value="{{ value }}"{% if branch == value %} selected{% endif %}>{{ value|capitalize }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="directory__field" for="council-specialty">Специальность ВАК
|
||||
<select class="directory__input" id="council-specialty" name="specialty">
|
||||
<option value="">Все специальности</option>
|
||||
@@ -63,7 +54,7 @@
|
||||
</label>
|
||||
<button class="button" type="submit">Применить</button>
|
||||
<button class="button button--secondary" type="submit" formaction="/admin/council/export.xlsx">Скачать XLSX</button>
|
||||
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</form>
|
||||
<div class="council__list-heading">
|
||||
<h3>Сотрудники</h3>
|
||||
@@ -89,7 +80,7 @@
|
||||
</summary>
|
||||
<div class="council__candidate-body">
|
||||
<p><strong>{{ candidate.assessment_status }}</strong>. Текущая степень: {{ candidate.selected_degree or 'не установлена' }}; документальное подтверждение: {{ 'есть' if candidate.degree_confirmed else 'нет' }}.</p>
|
||||
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }} · Монографии: {{ candidate.monograph_count }}{% if candidate.monograph_minimum %} / {{ candidate.monograph_minimum }} обязательно{% else %} (справочно){% endif %}</p>
|
||||
<p>Пункт: {{ candidate.clause or 'не определён' }} · Минимум: {{ candidate.minimum if candidate.minimum is not none else 'нужна отрасль' }} · Подтверждено: {{ candidate.confirmed_count }} · Предварительно: {{ candidate.total_count }} · К2: {{ candidate.k2_count }} · RSCI: {{ candidate.rsci_count }}</p>
|
||||
{% if candidate.assessment_reasons %}<ul class="council__evidence" aria-label="Причины проверки">{% for reason in candidate.assessment_reasons %}<li>{{ reason }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% if not candidate.articles %}<p class="council__hint">Доступных публикаций нет. Это не доказывает отсутствие работ сотрудника.</p>{% endif %}
|
||||
{% if specialty %}<p class="council__hint">Публикаций по выбранной специальности: {{ candidate.specialty_count }}</p>{% endif %}
|
||||
@@ -132,16 +123,16 @@
|
||||
</div>
|
||||
{% if matched > limit %}
|
||||
<nav class="directory__pagination" aria-label="Страницы сотрудников">
|
||||
{% if offset > 0 %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&branch={{ branch|urlencode }}&q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
{% if offset > 0 %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset - limit }}">Назад</a>{% endif %}
|
||||
<span class="directory__page">{{ offset // limit + 1 }} / {{ (matched + limit - 1) // limit }}</span>
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&branch={{ branch|urlencode }}&q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
{% if offset + limit < matched %}<a class="admin__link" href="?q={{ q|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}&offset={{ offset + limit }}">Далее</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="council__message">
|
||||
<h4>{% if q or specialty %}Сотрудники не найдены{% else %}Сотрудники с учёной степенью пока не найдены{% endif %}</h4>
|
||||
<p>{% if q or specialty %}Попробуйте другую фамилию или специальность. Вспомогательный подбор по журналам К1 может скрывать сотрудников с другими основаниями и не является условием соответствия пунктам 5–6.{% else %}Для списка требуется обнаруженная либо документально подтверждённая степень доктора, кандидата наук или PhD.{% endif %}</p>
|
||||
{% if q %}<a class="admin__link" href="?petition_date={{ petition_date.isoformat() if petition_date else '' }}&branch={{ branch|urlencode }}&specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
{% if q %}<a class="admin__link" href="?specialty={{ specialty|urlencode }}&sort={{ sort }}&direction={{ direction }}">Сбросить поиск</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.16.1"
|
||||
FRONTEND_VERSION = "0.16.1"
|
||||
BACKEND_VERSION = "0.16.1"
|
||||
APP_VERSION = "0.16.2"
|
||||
FRONTEND_VERSION = "0.16.2"
|
||||
BACKEND_VERSION = "0.16.2"
|
||||
@@ -1,6 +1,6 @@
|
||||
# Подтверждения публикационных критериев совета
|
||||
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Порог зависит от **отрасли предполагаемого включения**, а не от отрасли степени или автоматически выбранной специальности.
|
||||
Страница и XLSX читают один результат сервиса. Правила и ограничения исходного материала описаны в `app/data/COUNCIL_CRITERIA.md`, каталогов — в `app/data/Q1_Q2_CATALOG_GUIDE.md`. Публичная страница и XLSX настроены на **технические науки**: для доктора — 9 публикаций, для кандидата/PhD — 20. Пятилетний период рассчитывается относительно текущей даты Москвы. Дата ходатайства, монографии и связанные причины проверки не отображаются; подтверждение степени, вида, периода и основания работ по-прежнему требуется. Внутренний сервис сохраняет правила других отраслей для проверки исходной инструкции.
|
||||
|
||||
По умолчанию `app/data/council_evidence.json` содержит пустой набор подтверждений. Это намеренно: профиль, CSV и текущие списки журналов не заменяют документы. Без достаточных подтверждений вывод — «Требует проверки». Источники считаются неполными; отрицательное заключение из малого количества найденных работ не формируется.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
Прямая и условная связь журнала — аналитическая подсказка, не подтверждение любой статьи. Текст условий не вычисляется по ключевым словам и не трактуется как AND/OR. Отсутствие журнала в подборке означает «не оценён», а `not_established` относится к конкретной оценённой паре и не запрещает междисциплинарную статью.
|
||||
|
||||
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (значение отрасли из формы), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
|
||||
К записи публикации можно добавить `thematic_review` с полями `specialty_code`, `branch` (для публичной страницы — `технические`), `passport_id`, `passport_sha256`, `point_numbers` (числовой массив), `materials` (ссылки/описание проверенных материалов), `checked_conditions` (массив результатов проверки условий, допускается пустой массив), `result` (`confirmed` или `requires_check`), `source`, `checked_on`. Сервис проверяет выбранную специальность, отрасль, версию паспорта и номера направлений. Для 1.3.8 технический и физико-математический паспорта имеют отдельные ID и нумерацию; подмена отрасли не допускается. Оговорка 1.2.2 о названии диссертации не применяется автоматически к статье. Результат рассмотрения показывается отдельно и не вводит дополнительный числовой критерий.
|
||||
|
||||
## Каталоги и обновление
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -83,13 +83,11 @@ def main():
|
||||
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()
|
||||
page.get_by_label("Дата ходатайства").fill("2026-10-08")
|
||||
page.get_by_label("Отрасль предполагаемого включения").select_option("технические")
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.get_by_label("Дата ходатайства")).to_have_value("2026-10-08")
|
||||
expect(page.get_by_label("Отрасль предполагаемого включения")).to_have_value("технические")
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
summary = page.locator(".council__candidate-summary").first
|
||||
|
||||
+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"] == "0.16.1"
|
||||
assert response.json()["backend_version"] == "0.16.2"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
|
||||
@@ -218,7 +218,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "<script>" 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.16.1 · Frontend 0.16.1" in response.text
|
||||
assert "Backend 0.16.2 · Frontend 0.16.2" 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
|
||||
@@ -267,7 +267,7 @@ def test_xlsx_exports_all_filtered_workers_in_order_and_deduplicated_evidence(db
|
||||
]
|
||||
assert people["J2"].value == "https://www.hse.ru/staff/test"
|
||||
assert people["K2"].value is None
|
||||
assert people.max_column == 26
|
||||
assert people.max_column == 23
|
||||
assert works["L1"].value == "Страница ВШЭ"
|
||||
assert people.max_row == 29 and works.max_row == 29
|
||||
assert people["B2"].value == "Петров 27" and people["B29"].value == "Петров 00"
|
||||
@@ -570,7 +570,7 @@ def test_xlsx_specialties_are_explicit_with_and_without_filter(db_session, expor
|
||||
if not specialty:
|
||||
unmatched = next(dict(zip(staff[0], values)) for values in staff[1:] if values[1] == other.full_name)
|
||||
assert unmatched['Специальности ВАК'] == 'Не подтверждены'
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'Z{len(staff)}')
|
||||
assert book['Сотрудники'].auto_filter.ref.endswith(f'W{len(staff)}')
|
||||
assert book['Публикации'].auto_filter.ref.endswith(f'AA{len(works)}')
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import itertools
|
||||
import json
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
@@ -50,7 +50,7 @@ def work(db, employee, number=0, year=2024, kind="ARTICLE", basis="К1", **proof
|
||||
|
||||
def result(db, branch="технические", **kwargs):
|
||||
db.commit()
|
||||
return council.council_candidates(db, petition_date=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
return council.council_candidates(db, today=date(2026, 10, 8), branch=branch, **kwargs)["candidates"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch,rule", assessment.BRANCHES.items())
|
||||
@@ -145,7 +145,12 @@ def test_catalogs_union_percentiles_and_article_not_approved():
|
||||
assert publication_catalog(["0006-3835"], ["Wrong name"])[1]
|
||||
|
||||
|
||||
def test_http_validation_context_and_export_parity(db_session, proofs):
|
||||
def test_http_validation_context_and_export_parity(db_session, proofs, monkeypatch):
|
||||
class Clock:
|
||||
@staticmethod
|
||||
def now(zone):
|
||||
return datetime(2026, 10, 8, tzinfo=zone)
|
||||
monkeypatch.setattr(council, "datetime", Clock)
|
||||
degree = "Доктор технических наук"
|
||||
employee = person(db_session, [degree])
|
||||
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
|
||||
@@ -155,10 +160,10 @@ def test_http_validation_context_and_export_parity(db_session, proofs):
|
||||
app.dependency_overrides[get_db] = database
|
||||
try:
|
||||
client = TestClient(app)
|
||||
for query in ["petition_date=oops", "petition_date=0001-01-01", "branch=oops", "specialty=oops"]:
|
||||
for query in ["specialty=oops"]:
|
||||
assert client.get("/admin/council?" + query).status_code == 422
|
||||
assert client.get("/admin/council/export.xlsx?" + query).status_code == 422
|
||||
query = "?petition_date=2026-10-08&branch=технические&sort=confirmed_count"
|
||||
query = "?sort=confirmed_count"
|
||||
page = client.get("/admin/council" + query)
|
||||
assert page.status_code == 200 and "Соответствует публикационным критериям пункта 5" in page.text
|
||||
book = load_workbook(BytesIO(client.get("/admin/council/export.xlsx" + query).content))
|
||||
@@ -167,6 +172,12 @@ def test_http_validation_context_and_export_parity(db_session, proofs):
|
||||
assert row["Подтверждено"] == 9 and row["Минимум"] == 9
|
||||
assert row["Итог"] in page.text
|
||||
assert row["Отрасль оценки"] == "технические"
|
||||
assert not any("ходатайств" in str(c.value).lower() or "монограф" in str(c.value).lower() for sheet in book for cells in sheet for c in cells)
|
||||
assert "council-date" not in page.text and "Монографии" not in page.text
|
||||
assert "Дата ходатайства" not in page.text
|
||||
legacy = client.get("/admin/council/export.xlsx?petition_date=2000-01-01&branch=экономические")
|
||||
legacy_book = load_workbook(BytesIO(legacy.content))
|
||||
assert legacy_book["Сотрудники"]["Q2"].value == "технические"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -263,14 +274,15 @@ def test_degree_document_can_include_employee_without_profile_degree(db_session,
|
||||
assert value["clause"] == 6 and value["degree_confirmed"] and value["count"] == 0
|
||||
|
||||
|
||||
def test_confirmed_degree_needs_petition_and_branch_even_if_minimum_met(db_session, proofs):
|
||||
def test_technical_assessment_uses_current_period_without_petition(db_session, proofs):
|
||||
degree = "Доктор технических наук"
|
||||
employee = person(db_session, [degree])
|
||||
proofs([{**SOURCE, "degree": degree}], [work(db_session, employee, n) for n in range(9)])
|
||||
db_session.commit()
|
||||
value = council.council_candidates(db_session, today=date(2026, 10, 8))["candidates"][0]
|
||||
assert value["confirmed_count"] == 9 and value["assessment_status"] == "Требует проверки"
|
||||
assert any("Дата ходатайства" in r for r in value["assessment_reasons"])
|
||||
assert value["confirmed_count"] == value["minimum"] == 9
|
||||
assert value["assessment_status"].startswith("Соответствует")
|
||||
assert value["assessment_reasons"] == []
|
||||
|
||||
|
||||
def test_boundary_dates_require_sourced_policy(db_session, proofs):
|
||||
@@ -303,7 +315,7 @@ def test_manual_monograph_bases_respect_preliminary_type_policy(db_session, proo
|
||||
record["kind"] = "monograph"
|
||||
policy = {"publication_types": {**SOURCE, "values": ["monograph"]}} if admitted else {}
|
||||
proofs(publications=[record], policy=policy)
|
||||
full_result = council.council_candidates(db_session, petition_date=date(2026, 10, 8), branch="технические")
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
|
||||
candidate = full_result["candidates"][0]
|
||||
assert candidate[f"{flag}_count"] == candidate["total_count"] == int(admitted)
|
||||
# The policy permits a type; it does not itself confirm a quartile.
|
||||
@@ -344,7 +356,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title="Snapshot work", year=2025,
|
||||
publication_type="ARTICLE", source_hash="snapshot", raw_data={"journal": journal["name"], "issn": journal["issn"]}))
|
||||
db_session.commit()
|
||||
full_result = council.council_candidates(db_session, petition_date=date(2026, 10, 8), branch="технические")
|
||||
full_result = council.council_candidates(db_session, today=date(2026, 10, 8), branch="технические")
|
||||
candidate = full_result["candidates"][0]
|
||||
article = candidate["articles"][0]
|
||||
flag = "q2" if broken_index == 0 else "q1"
|
||||
@@ -357,7 +369,7 @@ def test_valid_snapshot_keeps_preliminary_metrics_when_other_is_broken(db_sessio
|
||||
assert "целостность каталога" in " ".join(str(cell.value) for row in book["Параметры"] for cell in row)
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
try:
|
||||
response = TestClient(app).get("/admin/council?petition_date=2026-10-08&branch=технические")
|
||||
response = TestClient(app).get("/admin/council")
|
||||
assert response.status_code == 200 and "целостность каталога" in response.text
|
||||
assert journal["name"] in response.text
|
||||
finally:
|
||||
|
||||
Reference in new issue
Block a user