fix: complete catalog pagination and recovery states
This commit is contained in:
31
README.md
31
README.md
@@ -43,9 +43,15 @@ uvicorn app.main:app --reload
|
||||
|
||||
В админке доступны:
|
||||
|
||||
- `Dashboard`: общая статистика, последний добавленный сотрудник, прогресс текущего/последнего парсинга и ручной запуск.
|
||||
- `Directory`: настраиваемая таблица сотрудников с фильтрами, сортировкой, пагинацией и выбором колонок.
|
||||
- `Runs`: история запусков, ошибки и progress bar.
|
||||
- «Обзор»: статистика, последний добавленный сотрудник, прогресс парсинга и ручной запуск.
|
||||
- «Сотрудники»: поиск, фильтры, сортировка, пагинация и выбор колонок. По умолчанию показаны ФИО, статус, должности, дата последнего обнаружения и внешний профиль. В диалоге колонок доступны наборы «Проверка», «Контакты» и «Все поля»; ранее сохранённый выбор сохраняется.
|
||||
- «Запуски»: история обходов, ошибки и доступный индикатор прогресса.
|
||||
|
||||
Все фильтры применяются кнопкой «Применить фильтры» с переходом на первую страницу. «Сбросить» очищает условия; при устаревшем номере страницы каталог возвращает первую. Подписи полей показывают выбранные условия, над таблицей указан диапазон результатов. Пояснения дат и статусов находятся под фильтрами.
|
||||
|
||||
Каталог поддерживает навигацию с клавиатуры и системный диалог колонок с Escape и возвратом фокуса. На узких экранах таблица прокручивается внутри страницы, колонка ФИО закреплена. Используется системный шрифт без внешних загрузок.
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -56,7 +62,9 @@ docker compose up -d --build --remove-orphans
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- Postgres: `localhost:5432`
|
||||
- PostgreSQL: `postgres:5432` внутри сети Compose; порт на хост не опубликован.
|
||||
|
||||
Compose запускает `api`, `worker` и `postgres`. API привязан к localhost; для внешнего доступа нужен настроенный reverse proxy. MCP удалён, endpoint `/mcp` отсутствует; REST API данных требует сессию администратора.
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
|
||||
@@ -71,8 +79,6 @@ docker compose up -d --build --remove-orphans
|
||||
|
||||
`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 не должен создавать дубликаты.
|
||||
|
||||
`list_employee_publications` сначала читает `employee_publications`; если детальных строк еще нет, возвращает старые публикации из `current_data`.
|
||||
|
||||
Новости сотрудников также хранятся в двух видах:
|
||||
|
||||
- краткий список остается внутри `employees.current_data.sections[].news_links`;
|
||||
@@ -116,4 +122,15 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.7`. Админка всегда показывает версии backend и frontend в footer.
|
||||
## Проверки
|
||||
|
||||
После установки зависимостей из `requirements.txt` выполните:
|
||||
|
||||
```bash
|
||||
python -m pytest -q
|
||||
node --check app/static/admin.js
|
||||
```
|
||||
|
||||
Тесты используют временную SQLite, без подключения к рабочей базе. Браузерная проверка: `pip install playwright`, `python -m playwright install chromium`, затем `python tests/browser_admin.py`. Она проверяет реальный рендеринг страниц, клавиатуру, диалог, фильтры и восстановление прогресса после ошибки. Скриншоты сохраняются во временную папку; путь выводится в конце.
|
||||
|
||||
Версия сервиса: `0.8.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
33
app/admin.py
33
app/admin.py
@@ -2,6 +2,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -93,19 +94,23 @@ def directory(
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
try:
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "directory_error.html", {}, status_code=503)
|
||||
return _render(
|
||||
request,
|
||||
"directory.html",
|
||||
@@ -121,7 +126,7 @@ def directory(
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
"offset": offset,
|
||||
"offset": page["offset"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -132,6 +132,8 @@ def list_employees_page(
|
||||
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()
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
:is(a, button, input, select):focus-visible {
|
||||
outline: 3px solid #14b8a6;
|
||||
:is(a, button, input, select, summary):focus-visible {
|
||||
outline: 3px solid #0f766e;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,6 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table__row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table__row:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
@@ -205,6 +201,19 @@
|
||||
|
||||
.button--compact {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.code {
|
||||
@@ -537,7 +546,7 @@
|
||||
|
||||
.directory__filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 2fr 1.25fr 2fr;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
@@ -547,7 +556,7 @@
|
||||
|
||||
.directory__filter-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
@@ -578,6 +587,9 @@
|
||||
}
|
||||
|
||||
.directory__input {
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
@@ -611,9 +623,18 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directory__help {
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.directory__help summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.directory-table {
|
||||
width: 100%;
|
||||
min-width: 960px;
|
||||
min-width: 640px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -628,10 +649,15 @@
|
||||
}
|
||||
|
||||
.directory-table__cell {
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 280px;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.directory-table .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-table__row {
|
||||
@@ -751,6 +777,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.directory-table [data-column="full_name"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-width: 130px;
|
||||
max-width: 160px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.admin__header {
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
@@ -780,11 +814,3 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.progress-bar__fill {
|
||||
transition-property: width;
|
||||
transition-duration: 0.25s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
"full_name",
|
||||
"status",
|
||||
"positions",
|
||||
"hse_start_year",
|
||||
"email",
|
||||
"academic_degree",
|
||||
"last_seen_at",
|
||||
"dismissed_at",
|
||||
"profile",
|
||||
];
|
||||
const storageKey = "miem.directory.columns";
|
||||
@@ -85,6 +81,11 @@
|
||||
function setupProgress() {
|
||||
const panel = document.querySelector("[data-progress-panel]");
|
||||
if (!panel) return;
|
||||
const state = document.createElement("p");
|
||||
state.className = "progress-panel__empty";
|
||||
state.setAttribute("role", "status");
|
||||
panel.append(state);
|
||||
let pending = false;
|
||||
|
||||
const update = (run) => {
|
||||
if (!run) return;
|
||||
@@ -111,17 +112,31 @@
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (pending) return true;
|
||||
pending = true;
|
||||
state.textContent = "Обновляем прогресс…";
|
||||
panel.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const response = await fetch("/api/crawl-runs/latest", { credentials: "same-origin" });
|
||||
const response = await fetch("/api/crawl-runs/latest", {
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) throw new Error("progress request failed");
|
||||
const data = await response.json();
|
||||
const run = data.running || data.latest;
|
||||
update(run);
|
||||
state.textContent = run ? "Прогресс обновлён" : "Запусков пока нет. Запустите парсинг.";
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (error) error.hidden = true;
|
||||
return Boolean(data.running);
|
||||
} catch (_error) {
|
||||
const error = document.querySelector("[data-progress-error]");
|
||||
if (error) error.hidden = false;
|
||||
state.textContent = "Показаны последние полученные данные. Прогресс может быть устаревшим.";
|
||||
return true;
|
||||
} finally {
|
||||
pending = false;
|
||||
panel.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<legend class="directory__filter-legend">Сортировка и страница</legend>
|
||||
<label class="directory__field">Сортировать по<select class="directory__input" name="sort">
|
||||
{% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %}
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Сортировка: {{ label }}</option>
|
||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<label class="directory__field">Направление<select class="directory__input" name="direction">
|
||||
@@ -54,17 +54,21 @@
|
||||
</select></label>
|
||||
<label class="directory__field">Сотрудников на странице<select class="directory__input" name="limit">
|
||||
{% for value in [25, 50, 100] %}
|
||||
<option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>На странице: {{ value }}</option>
|
||||
<option value="{{ value }}" {% if filters.limit == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
</fieldset>
|
||||
<input type="hidden" name="offset" value="{{ filters.offset }}">
|
||||
<div class="directory__filter-actions">
|
||||
<button class="button" type="submit">Применить фильтры</button>
|
||||
<a class="button button--ghost" href="/admin/directory">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="directory__help">
|
||||
<summary>Что означают даты и статусы</summary>
|
||||
<p>«Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — даты обнаружения сотрудника в списке источника, а не даты приёма и увольнения. «Требует проверки» означает, что профиль недоступен и увольнение ещё не подтверждено.</p>
|
||||
</details>
|
||||
|
||||
<div class="directory__table-wrap">
|
||||
<table class="directory-table" data-directory-table>
|
||||
<thead>
|
||||
|
||||
9
app/templates/directory_error.html
Normal file
9
app/templates/directory_error.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Каталог недоступен · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<h2 class="panel__title">Не удалось загрузить сотрудников</h2>
|
||||
<p>База данных временно недоступна. Повторите запрос; выбранные фильтры сохранятся.</p>
|
||||
<a class="button" href="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">Повторить загрузку</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.8.0"
|
||||
FRONTEND_VERSION = "0.8.0"
|
||||
BACKEND_VERSION = "0.8.0"
|
||||
APP_VERSION = "0.8.1"
|
||||
FRONTEND_VERSION = "0.8.1"
|
||||
BACKEND_VERSION = "0.8.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
126
tests/browser_admin.py
Normal file
126
tests/browser_admin.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Run from the repository root: python tests/browser_admin.py.
|
||||
|
||||
Requires Playwright and Chromium. Uses in-memory data and intercepted requests;
|
||||
never contacts the production service. Optional BROWSER_EXECUTABLE selects an
|
||||
already installed Chromium instead of the Playwright download.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
db.add_all(Employee(profile_key=f"staff:{i}", canonical_url=f"https://www.hse.ru/staff/{i}",
|
||||
full_name=f"Сотрудник {i:02d}", current_data={"positions": ["Преподаватель"]})
|
||||
for i in range(30))
|
||||
db.commit()
|
||||
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
|
||||
settings = Settings(_env_file=None, session_secret="browser-test-secret")
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
output = Path(mkdtemp(prefix="miem-admin-browser-"))
|
||||
pending = []
|
||||
|
||||
def serve(route):
|
||||
url = urlsplit(route.request.url)
|
||||
if url.path == "/api/crawl-runs/latest":
|
||||
pending.append(route)
|
||||
return
|
||||
response = client.get(url.path + ("?" + url.query if url.query else ""))
|
||||
route.fulfill(status=response.status_code, body=response.content,
|
||||
content_type=response.headers.get("content-type", "text/plain"))
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv("BROWSER_EXECUTABLE"))
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||
page.route("**/*", serve)
|
||||
errors = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.goto("http://miem.test/admin/directory?limit=25&offset=25")
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 26–30 из 30")
|
||||
assert page.locator("th:visible").count() == 5
|
||||
assert page.locator(".directory__table-wrap").evaluate("e => e.scrollWidth <= e.clientWidth")
|
||||
page.get_by_label("ФИО или ссылка").fill("Сотрудник 00")
|
||||
page.get_by_role("button", name="Применить фильтры").click()
|
||||
expect(page.get_by_role("status")).to_have_text("Показаны 1–1 из 1")
|
||||
assert "offset=" not in page.url
|
||||
trigger = page.get_by_role("button", name="Настроить колонки")
|
||||
trigger.focus()
|
||||
page.keyboard.press("Enter")
|
||||
dialog = page.get_by_role("dialog", name="Отображаемые колонки")
|
||||
expect(dialog).to_be_visible()
|
||||
for _ in range(22):
|
||||
page.keyboard.press("Tab")
|
||||
# Native dialogs may let Tab reach browser chrome, never background controls.
|
||||
assert dialog.evaluate("e => document.activeElement === document.body || e.contains(document.activeElement)")
|
||||
page.keyboard.press("Escape")
|
||||
expect(trigger).to_be_focused()
|
||||
trigger.click()
|
||||
page.get_by_role("button", name="Контакты", exact=True).click()
|
||||
page.keyboard.press("Escape")
|
||||
page.reload()
|
||||
expect(page.locator('th[data-column="email"]')).to_be_visible()
|
||||
page.evaluate("localStorage.clear()")
|
||||
page.goto("http://miem.test/admin/directory")
|
||||
page.screenshot(path=str(output / "desktop.png"), full_page=True)
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
|
||||
assert page.locator('th[data-column="full_name"]').evaluate("e => getComputedStyle(e).position") == "sticky"
|
||||
page.screenshot(path=str(output / "mobile.png"), full_page=True)
|
||||
trigger.click()
|
||||
expect(dialog).to_be_visible()
|
||||
assert dialog.evaluate("e => e.getBoundingClientRect().right <= innerWidth")
|
||||
page.screenshot(path=str(output / "mobile-dialog.png"), full_page=True)
|
||||
page.keyboard.press("Escape")
|
||||
page.get_by_label("ФИО или ссылка").fill("Нет совпадений")
|
||||
page.get_by_role("button", name="Применить фильтры").click()
|
||||
expect(page.get_by_text("Сотрудники не найдены", exact=True)).to_be_visible()
|
||||
expect(page.get_by_role("link", name="Сбросить фильтры", exact=True)).to_be_visible()
|
||||
page.goto("http://miem.test/admin")
|
||||
page.wait_for_function("document.querySelector('[data-progress-panel]').getAttribute('aria-busy') === 'true'")
|
||||
pending.pop(0).fulfill(status=503, body="unavailable")
|
||||
expect(page.locator("[data-progress-error]")).to_be_visible()
|
||||
expect(page.get_by_text("Показаны последние полученные данные.", exact=False)).to_be_visible()
|
||||
page.get_by_role("button", name="Повторить").click()
|
||||
page.wait_for_timeout(100)
|
||||
pending.pop(0).fulfill(json={"running": None, "latest": None})
|
||||
expect(page.locator("[data-progress-error]")).to_be_hidden()
|
||||
expect(page.get_by_text("Запусков пока нет. Запустите парсинг.", exact=True)).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
client.close()
|
||||
engine.dispose()
|
||||
print(f"Browser checks passed; screenshots: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -268,6 +268,18 @@ def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
||||
assert page["limit"] == 50
|
||||
|
||||
|
||||
def test_catalog_recovers_from_out_of_range_offset(db_session):
|
||||
db_session.add(Employee(profile_key="staff:page", canonical_url="https://www.hse.ru/staff/page", full_name="Page Person"))
|
||||
db_session.commit()
|
||||
|
||||
page = list_employees_page(db_session, q="Page", offset=100, limit=25)
|
||||
assert (page["offset"], page["page"], page["total"]) == (0, 1, 1)
|
||||
assert len(page["employees"]) == 1
|
||||
empty = list_employees_page(db_session, q="missing", offset=100)
|
||||
assert empty["offset"] == 0
|
||||
assert empty["employees"] == []
|
||||
|
||||
|
||||
def test_stats_payload_uses_latest_run_new_count(db_session):
|
||||
db_session.add(
|
||||
Employee(
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
assert "Сотрудники" in template
|
||||
assert "колонки" in template
|
||||
assert "Применить фильтры" in template
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "Сотрудников на странице" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Показаны" in template
|
||||
assert "Новости" in template
|
||||
|
||||
@@ -3,6 +3,7 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -17,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.0"
|
||||
assert response.json()["backend_version"] == "0.8.1"
|
||||
|
||||
|
||||
def test_mcp_endpoint_is_removed():
|
||||
@@ -81,6 +82,14 @@ def test_api_employees_and_stats_require_admin_session():
|
||||
assert stats.json()["new_in_last_run"] == 1
|
||||
assert run_details.status_code == 200
|
||||
assert run_details.json()["changes"]["new"][0]["full_name"] == "Alpha Person"
|
||||
directory = client.get("/admin/directory?q=Alpha&offset=100")
|
||||
assert directory.status_code == 200
|
||||
assert "Показаны 1–1 из 1" in directory.text
|
||||
assert 'name="offset"' not in directory.text
|
||||
assert "Alpha Person" in directory.text
|
||||
empty = client.get("/admin/directory?q=missing&offset=100")
|
||||
assert "Показаны 0–0 из 0" in empty.text
|
||||
assert "Сбросить фильтры" in empty.text
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@@ -128,3 +137,22 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
assert calls == [(employee_id, settings)]
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_directory_database_error_has_retry_and_preserves_filters(db_session, monkeypatch):
|
||||
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
|
||||
def unavailable(*args, **kwargs):
|
||||
raise OperationalError("SELECT", {}, Exception("private database details"))
|
||||
monkeypatch.setattr("app.admin.list_employees_page", unavailable)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(settings.admin_username, settings))
|
||||
response = client.get("/admin/directory?q=Alice")
|
||||
assert response.status_code == 503
|
||||
assert 'href="/admin/directory?q=Alice"' in response.text
|
||||
assert "Повторить загрузку" in response.text
|
||||
assert "private database details" not in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
Reference in New Issue
Block a user