Compare commits

...
Author SHA1 Message Date
codex cb15d11df4 feat: add preliminary council candidate view 2026-09-30 11:11:47 +03:00
admin 07bd42927b Merge pull request 'fix: run backup installer through sh' (#52) from fix/deploy-cron-installer-permission into main
Reviewed-on: admin/miem_workers#52
2026-09-17 10:12:16 +00:00
agent e49bcf6ba6 test: update deploy version expectation 2026-09-17 12:05:55 +03:00
agent 4cf6cba77c fix: run backup installer through sh 2026-09-17 12:04:16 +03:00
admin 9fc7de113a Merge pull request 'fix: ограничить PostgreSQL backup одним еженедельным dump' (#51) from fix/backup-retention into main
Reviewed-on: admin/miem_workers#51
2026-09-17 07:09:23 +00:00
agent 69c1dd52cf fix: report scheduled backup failures 2026-09-15 18:53:06 +03:00
agent b933f95302 fix: finalize weekly postgres backup retention 2026-09-15 18:50:22 +03:00
agent 384cd550d5 fix: schedule weekly full postgres backup 2026-09-15 18:45:27 +03:00
admin 5c3883beca Merge pull request 'fix: verify deployed runtime version' (#50) from fix/verify-production-release into main
Reviewed-on: admin/miem_workers#50
2026-09-15 15:13:55 +00:00
agent 144e62a35e fix: validate deploy version input 2026-09-15 17:59:23 +03:00
agent 8102556862 fix: bust admin stylesheet cache 2026-09-15 17:57:59 +03:00
agent 711c6ae7b0 fix: verify deployed runtime version 2026-09-15 17:54:18 +03:00
admin 580df29849 Merge pull request 'fix: restart production containers automatically' (#49) from fix/restart-containers into main
Reviewed-on: admin/miem_workers#49
2026-09-15 14:34:15 +00:00
agent 186f2d0804 chore: align package version and changelog 2026-09-15 17:29:50 +03:00
agent e4e64b92c1 fix: restart production containers automatically 2026-09-15 17:27:27 +03:00
admin aa43da2857 Merge pull request 'test: update health version expectation' (#48) from fix/update-version-test into main
Reviewed-on: admin/miem_workers#48
2026-09-15 13:40:20 +00:00
agent bee3dc9848 test: update health version expectation 2026-09-15 16:18:36 +03:00
admin d95133e257 Merge pull request 'fix: align directory filter actions' (#47) from fix/directory-filter-action-alignment into main
Reviewed-on: admin/miem_workers#47
2026-09-15 12:57:53 +00:00
agent 2bf6ff570c fix: align directory filter actions 2026-09-15 15:55:47 +03:00
admin e58156fbeb Merge pull request 'fix: retain only latest production backup' (#46) from fix/retain-single-db-backup into main
Reviewed-on: admin/miem_workers#46
2026-09-15 12:51:30 +00:00
agent ddb3ad1a72 fix: retain only latest production backup 2026-09-15 15:49:21 +03:00
17 changed files with 2268 additions and 27 deletions

No files matched your search

+21 -5
View File
@@ -23,6 +23,8 @@ jobs:
- run: python -m pip install -r requirements.txt
- run: python -m pytest -q
- run: node --check app/static/admin.js
- run: sh -n infra/postgres-backup.sh
- run: sh -n infra/install-postgres-backup-cron.sh
deploy:
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
@@ -37,9 +39,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Prepare release
id: prepare
shell: bash
run: |
set -euo pipefail
backend_version="$(sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p' app/version.py)"
case "$backend_version" in
''|*[!0-9.]*) exit 1 ;;
esac
echo "backend_version=$backend_version" >> "$GITHUB_OUTPUT"
tar --exclude=.git --exclude=.env -cf "$RUNNER_TEMP/$RELEASE_NAME" .
printf '%s\n' "$PROD_SSH_KEY" > "$RUNNER_TEMP/deploy_key"
printf '%s\n' "$PROD_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts"
@@ -67,22 +75,30 @@ jobs:
-o BatchMode=yes \
-o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" \
"$PROD_USER@$PROD_HOST" \
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' bash -s" <<'REMOTE'
"PROD_PATH='$PROD_PATH' RELEASE_NAME='$RELEASE_NAME' EXPECTED_BACKEND_VERSION='$EXPECTED_BACKEND_VERSION' bash -s" <<'REMOTE'
set -euo pipefail
cd "$PROD_PATH"
release="$HOME/$RELEASE_NAME"
mkdir -p "$HOME/backups/miem_workers"
backup="$HOME/backups/miem_workers/miem_workers-$(date +%Y%m%d-%H%M%S).sql"
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > "$backup"
test -f "$release"
archive_version="$(tar -xOf "$release" ./app/version.py | sed -n 's/^BACKEND_VERSION = "\(.*\)"$/\1/p')"
test "$archive_version" = "$EXPECTED_BACKEND_VERSION"
docker run --rm \
-v "$release:/src/release.tar:ro" \
-v "$PROD_PATH":/dst \
alpine:3.20 sh -c 'tar -xf /src/release.tar -C /dst'
sh /srv/miem_workers/infra/install-postgres-backup-cron.sh
docker compose up -d --build
curl --fail --silent --show-error http://127.0.0.1:8000/api/health
runtime_version="$(docker compose exec -T api python -c 'from app.version import BACKEND_VERSION; print(BACKEND_VERSION)')"
test "$runtime_version" = "$EXPECTED_BACKEND_VERSION"
health="$(curl --fail --silent --show-error http://127.0.0.1:8000/api/health)"
case "$health" in
*"\"status\":\"ok\""*"\"backend_version\":\"$EXPECTED_BACKEND_VERSION\""*) ;;
*) echo "Unexpected health response: $health" >&2; exit 1 ;;
esac
docker compose ps
rm -f "$release"
REMOTE
env:
PROD_HOST: ${{ secrets.PROD_HOST }}
PROD_USER: ${{ secrets.PROD_USER }}
EXPECTED_BACKEND_VERSION: ${{ steps.prepare.outputs.backend_version }}
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## 0.8.15
- Исправлен production deploy: cron installer запускается через `sh` и не требует executable-бита.
## 0.8.14
- Production CD больше не создаёт backup при каждом deploy; еженедельная задача
сохраняет один полный PostgreSQL backup с атомарной заменой; ошибки и
пропущенные из-за lock запуски пишутся в system log.
## 0.8.12
- Передача версии в production deploy ограничена безопасным форматом.
## 0.8.11
- CSS-ассеты админки получают версию в URL и обновляются после релиза без ручной очистки кэша браузера.
## 0.8.10
- Production deploy проверяет совпадение версии release-архива и запущенного API.
## 0.8.9
- Контейнеры API, worker и PostgreSQL автоматически перезапускаются после остановки или перезагрузки хоста.
## 0.8.8
- Исправлено вертикальное выравнивание кнопки и ссылки в действиях фильтров справочника.
## 0.8.7
- Production CD оставляет только последний PostgreSQL backup после успешного деплоя.
## 0.8.6
- Исправлена загрузка release-архива production CD в домашний каталог deploy-пользователя.
+12 -6
View File
@@ -77,14 +77,20 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
Новости сотрудников также хранятся в двух видах:
`employee_publications` содержит `publication_id`, название, год, тип публикации, язык, статус, ссылку на карточку HSE Publications, DOI, внешние/document-ссылки, citation text, аннотацию, описание, авторов, raw JSON ответа `searchPubs` и `source_hash` для безопасного повторного upsert. Уникальность поддерживается по `(employee_id, publication_id)` и `(employee_id, source_hash)`, поэтому повторный crawl не должен создавать дубликаты.
Новости сотрудников также хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
## Кандидаты в диссертационный совет
Ссылка в админке открывает `/admin/council`. Страница использует действующих сотрудников и докторские степени из их профилей, а публикации — из предоставленной выгрузки `app/data/council_articles.csv`. Публикации сопоставляются только по полному ФИО при единственном совпадении; повторный DOI (или одинаковые название и год при отсутствии DOI) учитывается один раз. Порог — 10 предварительно подходящих публикаций за скользящие пять лет от текущей даты по Москве.
Список **предварительный**: в CSV указаны год и квартиль Scopus/WoS, но нет точной даты, типа материала, журнала, источника квартиля, подтверждения К1 и RSCI. На границах периода год не позволяет проверить точную дату. Статьи с Q1/Q2 учитываются с пометкой источника; отсутствие кандидата не доказывает недостаточную публикационную активность. Для подтверждённого отбора нужны проверка журналов и дат и дополнительные источники публикаций. Выгрузка поставляется вместе с релизом и обновляется отдельным изменением репозитория.
## Парсинг
@@ -122,7 +128,7 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down
```
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. Перед обновлением workflow сохраняет PostgreSQL backup в домашний каталог deploy-пользователя, пересобирает `api` и `worker` и проверяет `/api/health`. Одновременно выполняется только один production deploy.
Production deploy выполняется job `deploy` workflow `CI` после успешного `test` на push в `main` или вручную. Для него нужны Actions secrets `PROD_HOST`, `PROD_USER`, `PROD_SSH_KEY` и `PROD_KNOWN_HOSTS`; `.env` и ключи в репозиторий не добавляются. PostgreSQL backup выполняется на VPS раз в неделю user cron deploy-пользователя через `infra/postgres-backup.sh`: `pg_dumpall` сохраняет весь кластер в `~/backups/postgres/postgres-latest.sql`. Новый dump сначала создаётся во временный файл и заменяет старый только после успешного завершения; предыдущие dump-файлы `miem_workers` удаляются после успешного создания нового. Одновременно выполняется только один production deploy.
## Проверки
@@ -135,4 +141,4 @@ node --check app/static/admin.js
Тесты с данными используют временную SQLite; отдельные API smoke-тесты запускают приложение с его текущей конфигурацией. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
Версия сервиса: `0.8.6`. Админка всегда показывает версии backend и frontend в footer.
Версия сервиса: `0.9.0`. Админка всегда показывает версии backend и frontend в footer.
+19 -7
View File
@@ -17,8 +17,9 @@ from app.services.admin_data import (
run_payload,
stats_payload,
)
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawl_control import get_running_run, run_crawl_if_idle
from app.services.crawler import refresh_dismissed_status, refresh_employee
from app.services.council import council_candidates
from app.version import BACKEND_VERSION, FRONTEND_VERSION
router = APIRouter(prefix="/admin")
@@ -73,8 +74,8 @@ def employees(
return RedirectResponse("/admin/directory", status_code=303)
@router.get("/directory", response_class=HTMLResponse)
def directory(
@router.get("/directory", response_class=HTMLResponse)
def directory(
request: Request,
status: str | None = None,
q: str | None = None,
@@ -129,10 +130,21 @@ def directory(
"offset": page["offset"],
},
},
)
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
)
@router.get("/council", response_class=HTMLResponse)
def council(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
require_admin(request, settings)
try:
result = council_candidates(db)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True}, status_code=503)
return _render(request, "council.html", {"error": False, **result})
@router.get("/employees/{employee_id}", response_class=HTMLResponse)
def employee_detail(
employee_id: int,
request: Request,
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
"""Preliminary council candidates from the supplied publication export."""
import calendar
import csv
import re
from collections import Counter, defaultdict
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Employee
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)
def _name(value: str) -> str:
return " ".join(value.casefold().replace("ё", "е").split())
def _doi(value: str) -> str:
value = value.strip().lower()
for prefix in ("https://doi.org/", "http://doi.org/", "doi:"):
if value.startswith(prefix):
value = value[len(prefix):]
return value
def period(today: date) -> tuple[date, date]:
year = today.year - 5
return date(year, today.month, min(today.day, calendar.monthrange(year, today.month)[1])), today
@lru_cache(maxsize=1)
def _articles_by_author() -> dict[str, list[dict]]:
articles = defaultdict(list)
with EXPORT.open(encoding="utf-8-sig", newline="") as source:
for row_number, row in enumerate(csv.DictReader(source, delimiter=";"), 2):
sources = []
for label, quartile, identifier in (
("Scopus", "Квартиль scopus", "EID (Scopus)"),
("Web of Science", "Квартиль wos", "UT (WoS)"),
):
value = (row[quartile] or "").strip().upper()
if value in {"Q1", "Q2"}:
sources.append({"name": label, "quartile": value, "identifier": (row[identifier] or "").strip()})
if not sources:
continue
try:
year = int(row["Год"])
except (TypeError, ValueError):
continue
title = (row["Название публикации"] or "").strip()
if not title:
continue
doi = _doi(row["DOI"] or "")
article = {
"title": title,
"year": year,
"doi": doi,
"doi_url": f"https://doi.org/{quote(doi, safe='/')}" if DOI.fullmatch(doi) else None,
"sources": sources,
"row_number": row_number,
}
for author in set(_name(value) for value in (row["Авторы НИУ ВШЭ"] or "").split(";")) - {""}:
articles[author].append(article)
return articles
def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]:
unique = {}
for article in articles:
if not start.year <= article["year"] <= end.year:
continue
key = article["doi"] or (_name(article["title"]), article["year"])
if key not in unique:
unique[key] = {**article, "sources": list(article["sources"])}
else:
known = {item["name"] for item in unique[key]["sources"]}
unique[key]["sources"].extend(
item for item in article["sources"] if item["name"] not in known
)
return sorted(unique.values(), key=lambda item: (-len(item["sources"]), -item["year"], item["title"].casefold()))
def council_candidates(db: Session, today: date | None = None) -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
employees = db.scalars(select(Employee).where(Employee.status == "active")).all()
name_counts = Counter(_name(employee.full_name or "") for employee in employees)
by_author = _articles_by_author()
candidates = []
for employee in employees:
author = _name(employee.full_name or "")
if not author or name_counts[author] != 1:
continue
try:
degrees = academic_degrees(employee.current_data if isinstance(employee.current_data, dict) else {})
except (AttributeError, TypeError):
continue
degree = next((value for value in degrees if DOCTOR.fullmatch(value)), None)
if not degree:
continue
articles = _unique_articles(by_author.get(author, []), start, end)
if len(articles) >= 10:
candidates.append({"id": employee.id, "name": employee.full_name, "degree": degree,
"count": len(articles), "articles": articles})
candidates.sort(key=lambda item: (-item["count"], item["name"].casefold()))
return {"candidates": candidates, "start": start, "end": end, "total": len(candidates)}
+78 -2
View File
@@ -183,6 +183,7 @@
}
.button {
box-sizing: border-box;
min-height: 40px;
padding: 10px 14px;
border: 0;
@@ -777,7 +778,7 @@
}
}
@media (max-width: 620px) {
@media (max-width: 620px) {
.directory-table [data-column="full_name"] {
position: sticky;
left: 0;
@@ -814,4 +815,79 @@
.directory__field {
font-size: 13px;
}
}
}
.council {
display: grid;
gap: 24px;
color: #25313a;
}
.council__hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 32px;
padding: clamp(24px, 4vw, 48px);
color: #f9f6f0;
background: #263640;
border-radius: 16px;
}
.council__hero-copy { max-width: 680px; }
.council__title { margin: 0; font-family: Georgia, serif; font-size: clamp(29px, 4vw, 48px); line-height: 1.12; letter-spacing: -0.025em; }
.council__intro { max-width: 60ch; margin: 16px 0 0; color: #e3e8e5; line-height: 1.55; }
.council__total { margin: 0; min-width: 160px; color: #e3e8e5; font-size: 14px; line-height: 1.4; }
.council__total strong { display: block; color: #f6d49b; font-size: 42px; font-variant-numeric: tabular-nums; line-height: 1; }
.council__criteria {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
padding-block: 6px 22px;
border-bottom: 1px solid #cdd4d5;
}
.council__criteria p { display: grid; align-content: start; gap: 5px; margin: 0; }
.council__criteria strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.council__criteria span { color: #4c5b64; font-size: 13px; }
.council__notice { padding: 16px 20px; background: #f6efe2; border-radius: 10px; line-height: 1.55; }
.council__notice strong { color: #593b20; }
.council__list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.council__list-heading h3 { margin: 0; font-family: Georgia, serif; font-size: 27px; }
.council__list-heading p { margin: 0; color: #4c5b64; font-size: 14px; }
.council__list { display: grid; gap: 10px; }
.council__candidate { overflow: hidden; background: #fff; border: 1px solid #d6dddc; border-radius: 12px; }
.council__candidate[open] { border-color: #8fa8a5; }
.council__candidate-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 20px; min-height: 76px; padding: 14px 20px; cursor: pointer; list-style: none; }
.council__candidate-summary::-webkit-details-marker { display: none; }
.council__candidate-summary:hover { background: #f3f7f5; }
.council__candidate-identity { display: grid; gap: 4px; min-width: 0; }
.council__candidate-identity strong { font-size: 17px; overflow-wrap: anywhere; }
.council__candidate-identity small, .council__candidate-count small { color: #4c5b64; font-size: 12px; }
.council__candidate-count { display: grid; gap: 2px; text-align: end; }
.council__candidate-count strong { font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
.council__candidate-cue { width: 9px; height: 9px; border-inline-end: 2px solid #345e59; border-bottom: 2px solid #345e59; transform: rotate(45deg); }
.council__candidate[open] .council__candidate-cue { transform: rotate(225deg); }
.council__candidate-body { padding: 20px; border-top: 1px solid #e4e9e8; }
.council__articles { display: grid; gap: 0; margin: 16px 0 0; padding-inline-start: 22px; }
.council__article { padding: 14px 4px; border-top: 1px solid #e7ebea; }
.council__article::marker { color: #5e746f; font-variant-numeric: tabular-nums; }
.council__article-heading { display: flex; justify-content: space-between; gap: 16px; line-height: 1.45; }
.council__article-heading strong { font-weight: 600; overflow-wrap: anywhere; }
.council__article-heading span { color: #4c5b64; font-variant-numeric: tabular-nums; }
.council__article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin-top: 9px; color: #4c5b64; font-size: 12px; }
.council__source { padding: 4px 8px; color: #244b46; background: #e7f0eb; border-radius: 4px; font-weight: 700; overflow-wrap: anywhere; }
.council__message { padding: 28px; background: #fff; border-radius: 12px; }
.council__message h3, .council__message h4 { margin: 0 0 8px; font-size: 19px; }
.council__message p { margin: 0; color: #4c5b64; line-height: 1.5; }
@media (max-width: 700px) {
.council__hero, .council__list-heading { align-items: start; flex-direction: column; }
.council__total strong { display: inline; margin-inline-end: 8px; font-size: 28px; }
.council__criteria { grid-template-columns: 1fr; gap: 14px; }
.council__candidate-summary { grid-template-columns: minmax(0, 1fr) auto 14px; gap: 10px; padding: 14px; }
.council__candidate-count small { max-width: 72px; }
.council__candidate-body { padding: 14px; }
}
+2 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MIEM Employees{% endblock %}</title>
<link rel="stylesheet" href="/static/admin.css">
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
</head>
<body class="admin">
<a class="admin__skip-link" href="#main-content">Перейти к содержимому</a>
@@ -13,6 +13,7 @@
<nav class="admin__nav">
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
<form method="post" action="/admin/logout">
<button class="button button--ghost" type="submit">Выйти</button>
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Кандидаты в диссертационный совет · MIEM Employees{% endblock %}
{% block content %}
<section class="council">
<header class="council__hero">
<div class="council__hero-copy">
<h2 class="council__title">Кандидаты в диссертационный совет</h2>
<p class="council__intro">Сотрудники с докторской степенью в профиле и публикациями с отметками Q1 или Q2 в имеющейся выгрузке. Список помогает начать проверку состава комиссии.</p>
</div>
{% if not error %}<p class="council__total"><strong>{{ total }}</strong> предварительно подходят</p>{% endif %}
</header>
{% if error %}
<div class="council__message" role="alert">
<h3>Не удалось загрузить список</h3>
<p>Проверьте доступность базы сотрудников и файла публикаций, затем обновите страницу.</p>
</div>
{% else %}
<div class="council__criteria" aria-label="Правила предварительного отбора">
<p><strong>10 и более</strong><span>различных публикаций</span></p>
<p><strong>{{ start.strftime('%d.%m.%Y') }} — {{ end.strftime('%d.%m.%Y') }}</strong><span>скользящие пять лет</span></p>
<p><strong>К1 · RSCI · Q1/Q2</strong><span>итоговые основания отбора</span></p>
</div>
<div class="council__notice" role="note">
<strong>Предварительный список.</strong> Сейчас учитываются только отметки Q1/Q2 в CSV для Scopus и Web of Science. В нём нет названия журнала, точной даты статьи, типа материала и подтверждения К1 или RSCI. Год на границе периода и принадлежность к журналу нужно проверить. Сотрудники с публикациями только в К1 или RSCI могут отсутствовать.
</div>
<div class="council__list-heading">
<h3>Сотрудники</h3>
<p>Откройте строку, чтобы увидеть публикации и основания.</p>
</div>
{% if candidates %}
<div class="council__list">
{% for candidate in candidates %}
<details class="council__candidate">
<summary class="council__candidate-summary">
<span class="council__candidate-identity"><strong>{{ candidate.name }}</strong><small>{{ candidate.degree }}</small></span>
<span class="council__candidate-count"><strong>{{ candidate.count }}</strong><small>публикаций по выгрузке</small></span>
<span class="council__candidate-cue" aria-hidden="true"></span>
</summary>
<div class="council__candidate-body">
<a class="admin__link" href="/admin/employees/{{ candidate.id }}">Открыть профиль сотрудника</a>
<ol class="council__articles">
{% for article in candidate.articles %}
<li class="council__article">
<div class="council__article-heading">
<strong>{{ article.title }}</strong>
<span>{{ article.year }}</span>
</div>
<div class="council__article-meta">
{% for source in article.sources %}
<span class="council__source">{{ source.name }} {{ source.quartile }}{% if source.identifier %} · {{ source.identifier }}{% else %} · ID не указан{% endif %}</span>
{% endfor %}
{% if article.doi_url %}<a class="admin__link" href="{{ article.doi_url }}" target="_blank" rel="noopener noreferrer">DOI</a>{% endif %}
<span>Строка {{ article.row_number }} в CSV</span>
</div>
</li>
{% endfor %}
</ol>
</div>
</details>
{% endfor %}
</div>
{% else %}
<div class="council__message">
<h4>Кандидаты пока не найдены</h4>
<p>По доступной выгрузке нет действующих сотрудников с докторской степенью, указанной в профиле, и десятью предварительно подходящими публикациями. Это не означает, что таких сотрудников нет.</p>
</div>
{% endif %}
{% endif %}
</section>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход · MIEM Employees</title>
<link rel="stylesheet" href="/static/admin.css">
<link rel="stylesheet" href="/static/admin.css?v={{ frontend_version }}">
</head>
<body class="admin">
<main class="admin__main">
+3 -3
View File
@@ -1,3 +1,3 @@
APP_VERSION = "0.8.6"
FRONTEND_VERSION = "0.8.6"
BACKEND_VERSION = "0.8.6"
APP_VERSION = "0.9.0"
FRONTEND_VERSION = "0.9.0"
BACKEND_VERSION = "0.9.0"
+3
View File
@@ -1,6 +1,7 @@
services:
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-miem_workers}
POSTGRES_USER: ${POSTGRES_USER:-miem}
@@ -15,6 +16,7 @@ services:
api:
build: .
restart: always
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
env_file: .env
environment:
@@ -27,6 +29,7 @@ services:
worker:
build: .
restart: always
command: python -m app.worker
env_file: .env
environment:
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
temporary=$(mktemp)
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
trap 'rm -f -- "$temporary"' EXIT HUP INT TERM
crontab -l 2>/dev/null | grep -v 'postgres-backup.sh' > "$temporary" || true
printf '%s\n' "30 3 * * 0 flock -w 3600 /tmp/miem-postgres-backup.lock $app_dir/infra/postgres-backup.sh || logger -t miem-postgres-backup -- 'backup skipped or failed'" >> "$temporary"
crontab "$temporary"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
umask 077
app_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
backup_dir="$HOME/backups/postgres"
backup="$backup_dir/postgres-latest.sql"
temporary="$backup.tmp.$$"
log() { logger -t miem-postgres-backup -- "$*" 2>/dev/null || true; }
mkdir -p "$backup_dir"
trap 'status=$?; rm -f -- "$temporary"; [ "$status" -eq 0 ] || log "backup failed"; exit "$status"' EXIT HUP INT TERM
cd "$app_dir"
docker compose exec -T postgres sh -c 'pg_dumpall -U "$POSTGRES_USER"' > "$temporary"
test -s "$temporary"
mv "$temporary" "$backup"
find "$HOME/backups/miem_workers" -maxdepth 1 -type f -name 'miem_workers-*.sql' -delete 2>/dev/null || true
log "created $backup"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "miem-workers"
version = "0.8.6"
version = "0.9.0"
description = "MIEM employees parser, admin API, and web admin"
requires-python = ">=3.11"
dependencies = [
+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.8.6"
assert response.json()["backend_version"] == "0.9.0"
def test_api_employees_and_stats_require_admin_session():
+76
View File
@@ -0,0 +1,76 @@
import csv
from datetime import date
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 import council
def test_council_counts_unique_articles_and_only_active_doctors(db_session, monkeypatch, tmp_path):
fields = ["Название публикации", "Год", "DOI", "EID (Scopus)", "UT (WoS)",
"Авторы НИУ ВШЭ", "Квартиль scopus", "Квартиль wos"]
export = tmp_path / "articles.csv"
with export.open("w", encoding="utf-8-sig", newline="") as output:
writer = csv.DictWriter(output, fieldnames=fields, delimiter=";")
writer.writeheader()
for number in range(10):
writer.writerow({"Название публикации": f"Article {number}", "Год": 2024,
"DOI": f"10.1234/{number}", "EID (Scopus)": f"eid-{number}",
"Авторы НИУ ВШЭ": "Иванов Иван Иванович; Петров Пётр Петрович",
"Квартиль scopus": "Q1"})
writer.writerow({"Название публикации": "Article 0", "Год": 2024, "DOI": "10.1234/0",
"UT (WoS)": "ut-0", "Авторы НИУ ВШЭ": "Иванов Иван Иванович",
"Квартиль wos": "Q2"})
monkeypatch.setattr(council, "EXPORT", export)
council._articles_by_author.cache_clear()
db_session.add_all([
Employee(profile_key="doctor", canonical_url="https://example.org/1", full_name="Иванов Иван Иванович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Доктор технических наук"]}]}),
Employee(profile_key="candidate", canonical_url="https://example.org/2", full_name="Петров Пётр Петрович",
status="active", current_data={"sections": [{"title": "Учёные степени",
"items": ["Кандидат технических наук"]}]}),
])
db_session.commit()
result = council.council_candidates(db_session, date(2026, 9, 30))
assert result["total"] == 1
assert result["candidates"][0]["count"] == 10
assert len(result["candidates"][0]["articles"][0]["sources"]) == 2
assert council.period(date(2024, 2, 29))[0] == date(2019, 2, 28)
settings = Settings(_env_file=None, session_secret="test-session-secret")
monkeypatch.setattr("app.admin.council_candidates", lambda db: council.council_candidates(db, date(2026, 9, 30)))
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
assert response.status_code == 200
assert "Иванов Иван Иванович" in response.text
assert "Article 0" in response.text
assert "публикаций по выгрузке" in response.text
finally:
app.dependency_overrides.clear()
council._articles_by_author.cache_clear()
def test_council_page_requires_admin_and_explains_preliminary_result(db_session, monkeypatch):
monkeypatch.setattr(council, "_articles_by_author", lambda: {})
settings = Settings(_env_file=None, session_secret="test-session-secret")
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_settings] = lambda: settings
try:
client = TestClient(app)
assert client.get("/admin/council", follow_redirects=False).status_code in (302, 303, 401, 403)
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
response = client.get("/admin/council")
assert response.status_code == 200
assert "Предварительный список" in response.text
assert "К1 или RSCI могут отсутствовать" in response.text
finally:
app.dependency_overrides.clear()