Compare commits

...
Author SHA1 Message Date
codex 201e4dd299 docs: route production operations through MIEM infra-platform
CI / test (pull_request) Successful in 10m40s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-11-11 08:59:34 +00:00
admin 22e1bd3f2e Merge pull request 'Фильтр ППС и сворачиваемый раздел новостей сотрудников' (#73) from feature/pps-employee-filter into main
CI / test (push) Successful in 10m46s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Skipped
Reviewed-on: #73
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-29 10:00:50 +00:00
codex caae2270dc docs: document collapsed employee news section
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:32:23 +03:00
codex cdf879fdf5 fix: show employee news once in a collapsed section
CI / test (pull_request) Successful in 10m45s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:31:15 +03:00
codex fd3008dfde docs: document PPS employee filter in changelog
CI / test (pull_request) Successful in 10m43s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:27:17 +03:00
codex 87429e463c feat: add PPS employee directory filter
CI / test (pull_request) Successful in 10m47s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (pull_request) Skipped
2026-10-28 18:21:25 +03:00
admin ac9b1cebd1 Merge pull request 'Интерактивный подбор диссовета в XLSX и единый расчёт «Всего»' (#72) from feature/council-xlsx-selection into main
CI / test (push) Successful in 10m32s
CI / Fingerprint diagnostics (production deployment temporarily disabled) (push) Has been skipped
Reviewed-on: #72
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-13 07:48:57 +00:00
14 changed files with 258 additions and 107 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.5.1
- В карточке сотрудника убран дублирующий блок `news`. Раздел «В новостях» со ссылками теперь можно раскрывать и сворачивать; при загрузке карточки он закрыт по умолчанию.
## 1.5.0
- В справочник сотрудников добавлен чекбокс «ППС»: отбор по словам «кафедра» или «департамент» в должностях без учёта регистра. Фильтр совмещается с остальными условиями и сохраняется при переходе между страницами; в API доступен параметр `pps=true`.
## 0.18.1
- Проверка К1/К2 учитывает ISSN/eISSN исходной записи вместе с идентификаторами DOI, в том числе без сетевого поиска. Противоречие названия и ISSN не превращается в предварительное совпадение.
+91 -91
View File
@@ -1,48 +1,48 @@
# MIEM Employees Server
# MIEM Employees Server
Сервис собирает сотрудников МИЭМ с сайта ВШЭ, хранит карточки и историю обновлений в Postgres и показывает минимальную админку.
## Архитектура
## Архитектура
- `api`: FastAPI, REST API, HTML-админка и healthcheck.
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
- `postgres`: основная БД.
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
## Переменные окружения
Скопируйте `.env.example` в `.env` и поменяйте секреты:
```bash
cp .env.example .env
```
Основные настройки:
- `DATABASE_URL`: строка подключения SQLAlchemy.
- `postgres`: основная БД.
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
## Переменные окружения
Скопируйте `.env.example` в `.env` и поменяйте секреты:
```bash
cp .env.example .env
```
Основные настройки:
- `DATABASE_URL`: строка подключения SQLAlchemy.
- `SOURCE_URL`: список сотрудников МИЭМ.
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
- `SESSION_SECRET`: секрет подписи cookie.
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
## Локальный запуск
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
Админка: `http://localhost:8000/admin`.
В админке доступны:
- `SESSION_SECRET`: секрет подписи cookie.
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
- `MAX_AUTO_DISMISSALS_PER_RUN`: защитный лимит массовых автоматических увольнений за один запуск, по умолчанию `25`.
## Локальный запуск
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
Админка: `http://localhost:8000/admin`.
В админке доступны:
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
@@ -52,7 +52,7 @@ uvicorn app.main:app --reload
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
## Пользователи и личный кабинет
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
@@ -71,37 +71,37 @@ uvicorn app.main:app --reload
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
## Docker Compose
```bash
## Docker Compose
```bash
docker compose up -d --build --remove-orphans
```
По умолчанию:
- API и админка: `http://localhost:8000`
```
По умолчанию:
- API и админка: `http://localhost:8000`
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. REST API данных требует сессию администратора.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
## Наполнение БД
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
Публикации теперь хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].publications` для обратной совместимости;
- детальные записи сохраняются в `employee_publications` и связываются с сотрудником через `employee_id`.
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
## Наполнение БД
Основная карточка сотрудника хранится в `employees`: профиль, статус, даты обнаружения/увольнения, текущий JSON `current_data`, checksum и версия парсера. История успешных изменений сохраняется в `employee_snapshots` вместе с JSON-снимком и сжатым HTML профиля.
Публикации теперь хранятся в двух видах:
- краткий список остается внутри `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 не должен создавать дубликаты.
Новости сотрудников также хранятся в двух видах:
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
- краткий список остается внутри `employees.current_data.sections[].news_links`;
- нормализованные карточки из вкладки «В новостях» сохраняются в `employee_news_links`.
`employee_news_links` содержит название новости, ссылку, краткое описание, дату публикации, год публикации, raw JSON карточки и `source_hash`. Уникальность поддерживается по `(employee_id, url)` и `(employee_id, source_hash)`, поэтому повторный crawl не создает дубликаты.
## Кандидаты в диссертационный совет
@@ -140,45 +140,45 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
После изменения JSON/PDF пересоберите образ и пересоздайте API: данные включены в образ и кешируются в процессе.
## Парсинг
## Парсинг
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
```bash
```bash
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
```
Алгоритм обновления:
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
- новые сотрудники добавляются в `employees`;
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
- старые URL сохраняются в истории `employee_profile_urls`;
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
```
Алгоритм обновления:
- найденные сотрудники получают статус `active` и обновленный `last_seen_at`;
- новые сотрудники добавляются в `employees`;
- если профиль перенесен на другой URL, он сопоставляется с прежней записью по единственному точному совпадению ФИО;
- старые URL сохраняются в истории `employee_profile_urls`;
- количество новых сотрудников за запуск сохраняется в `crawl_runs.new_count`;
- публикации из HSE Publications записываются в `employee_publications`, а краткий список остается в JSON профиля;
- новости из блока «В новостях» записываются в `employee_news_links`, а краткий список остается в JSON профиля;
- один `404` старого профиля переводит сотрудника в статус `verification_required`, а не в `dismissed`;
- статус `dismissed` устанавливается только после нескольких последовательных проверок `404`/`410`;
- сетевые ошибки и ответы `5xx` не считаются подтверждением увольнения;
- если число кандидатов на увольнение превышает защитный лимит, автоматическое увольнение приостанавливается;
- кнопка «Проверить уволенных» сверяет только их profile_key с текущим списком источника и возвращает найденных сотрудников в `active` без обновления содержимого профиля;
- каждый успешный новый или измененный разбор сохраняет запись в `employee_snapshots`;
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
- неизмененные профили учитываются в `crawl_runs.skipped_count` и не получают новый snapshot.
Во время выполнения парсинга `found_count`, `parsed_count`, `skipped_count` и `error_count` обновляются в базе. Админка опрашивает `/api/crawl-runs/latest` и показывает прогресс как `(parsed_count + skipped_count + error_count) / found_count`.
## Обслуживание
```bash
```bash
docker compose logs -f api
docker compose logs -f worker
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
docker compose down
```
Production deployment временно отключён до установки отдельного deployment-доступа Workers. Workflow `CI` запускает тесты на push в `main` и pull_request в `main`; шаги Upload release и Deploy and verify безусловно отключены для всех событий и значений input. Стандартные имена deployment 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` удаляются после успешного создания нового. Эта правка не меняет существующий backup cron.
Развёртывание на новом VPS ведётся через общую платформу МИЭМ. Канонический домен `workers.miem.space`. Выбор образа, восстановление PostgreSQL, доступ, healthcheck и откат описаны в [процедуре первого запуска](https://git.vakanaut.ru/admin/infra-platform/src/branch/main/docs/runbooks/REPLACEMENT_VPS_FIRST_ROLLOUT.md). Фактические статусы публикуются в Infrastructure Registry. [Карточка проекта](https://git.vakanaut.ru/admin/infrastructure-registry/src/branch/main/environments/miem/projects/miem_workers/index.md).
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Возврат production deployment требует отдельной reviewed правки после установки нового доступа.
Ручной запуск с `diagnostic_only=true` (по умолчанию) выводит только SHA256 fingerprint ключа `PROD_SSH_KEY`, без подключения к VPS. Публичный ключ передаётся через pipe, не сохраняется в файл или artifacts; shell tracing отключён. При `diagnostic_only=false` диагностический job пропускается: это значение также не разрешает deployment. Этот job относится к диагностике ключа. Выбор и публикация production-релиза выполняются в infra-platform.
## Проверки
+3
View File
@@ -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"],
+2
View File
@@ -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,
+16 -4
View File
@@ -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,
+23
View File
@@ -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;
+2 -1
View File
@@ -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 %}
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
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"] == "1.4.2"
assert response.json()["backend_version"] == "1.5.1"
def test_api_employees_and_stats_require_admin_session():
+1 -1
View File
@@ -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
+46
View File
@@ -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
+55
View File
@@ -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()