diff --git a/CHANGELOG.md b/CHANGELOG.md index 719fe7a..ee91851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.0.1 + +- Роль «Пользователь диссовета» добавляет раздел «Диссовет» к меню обычного пользователя и разрешает редактировать публикации. Без входа доступен только просмотр диссовета, включая публикации и выгрузку XLSX. +- Страницы запусков, их история и прогресс в API доступны только администраторам. +- Поля фильтров справочника и диссовета выделяются при фокусе контуром толщиной 2 px без отступа. +- При обновлении существующей базы с 1.x применяется миграция 014. Она добавляет новую роль и сохраняет назначения существующих пользователей. + ## 1.7.3 - Пошаговые подсказки и полная инструкция объясняют, как отбирать сотрудников по курсам повышения квалификации и начальному году. diff --git a/README.md b/README.md index e2534b8..a88d140 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,13 @@ uvicorn app.main:app --reload ## Пользователи и личный кабинет -`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён. +`/admin/users` доступен только администратору. Здесь можно создавать учётные записи, назначать роль, включать и отключать доступ, сбрасывать пароль и смотреть историю входов. + +- **Пользователь** — обзор, сотрудники, личный кабинет и подсказки по справочнику. +- **Пользователь диссовета** — те же разделы плюс «Диссертационный совет», подсказки по нему и редактирование публикаций. +- **Администратор** — все разделы, включая «Запуски» и управление пользователями. Список, подробности и текущий прогресс запусков ограничены этой ролью в HTML и API. Остальные роли получают в `/api/stats` сводку по сотрудникам. + +«Диссертационный совет», просмотр его публикаций и XLSX доступны публично. Открытая интерактивная документация API отключена. Для мониторинга доступен `/api/health`. Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории. @@ -67,7 +73,7 @@ uvicorn app.main:app --reload Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token. -При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт. +При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. При переходе с 1.x на 2.x сделайте штатную резервную копию существующей PostgreSQL-БД и примените `migrations/014_system_user_council_role.sql` владельцем таблицы. Миграция расширяет ограничение роли и сохраняет все существующие назначения. Для существующей SQLite-БД остановите приложение, сделайте копию файла БД и примените `sqlite3 -bail <файл БД> < migrations/sqlite/014_system_user_council_role.sql` из POSIX-оболочки. В новой БД ограничение создаётся вместе с таблицей. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт. Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД. @@ -118,7 +124,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены. -Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора. +Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Пользователь диссовета или администратор может выбрать карточку статьи мышью либо клавишами Enter или пробел и открыть форму редактирования названия, журнала и квартиля. Та же форма доступна в карточках основного списка. Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново. diff --git a/app/admin.py b/app/admin.py index 5dc5add..884bd52 100644 --- a/app/admin.py +++ b/app/admin.py @@ -11,8 +11,8 @@ from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.db import SessionLocal, get_db from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction -from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user, - optional_user, sign_session, authenticate, check_csrf) +from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, USER_ROLES, require_admin, require_user, + optional_user, sign_session, authenticate, can_use_council, check_csrf) import secrets from app.services.admin_data import ( employee_detail_payload, @@ -31,12 +31,16 @@ from app.version import BACKEND_VERSION, FRONTEND_VERSION router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)]) templates = Jinja2Templates(directory="app/templates") templates.env.filters["admin_datetime"] = format_admin_datetime +templates.env.globals["user_roles"] = USER_ROLES @router.get("", response_class=HTMLResponse) def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): - require_user(request, settings, db) - counts = stats_payload(db) + user = require_user(request, settings, db) + is_admin = user.role == "admin" + counts = stats_payload(db, include_runs=is_admin) + if not is_admin: + return _render(request, "dashboard.html", {"counts": counts}) counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0 counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0 run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(5)).all() @@ -185,7 +189,7 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c except (SQLAlchemyError, OSError, ValueError, KeyError): db.rollback() return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503) - return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result}) + return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": can_use_council(authenticated), **result}) @router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse) @@ -202,7 +206,7 @@ def council_employee_articles(employee_id: int, request: Request, specialty: str db.rollback() raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз") return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0], - "can_edit": authenticated is not None, **result}) + "can_edit": can_use_council(authenticated), **result}) class CouncilArticleEdit(BaseModel): @@ -220,7 +224,7 @@ class CouncilArticleEdit(BaseModel): def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): context = {"public_page": True, "employee": employee, "title": title, "journal": journal, - "can_edit": optional_user(request, settings, db) is not None} + "can_edit": can_use_council(optional_user(request, settings, db))} try: result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc") articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]} @@ -242,6 +246,8 @@ def council_publications(request: Request, employee: str = "", title: str = "", def edit_council_publication(request: Request, change: CouncilArticleEdit, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): actor = require_user(request, settings, db, lock=True) + if not can_use_council(actor): + raise HTTPException(403, "Редактирование доступно пользователям диссовета и администраторам") username = actor.username # A custom header plus JSON prevents cross-site form submissions; CORS is disabled. if request.headers.get("X-Council-Edit") != "1": @@ -378,7 +384,7 @@ def refresh_employee_detail( @router.get("/runs", response_class=HTMLResponse) def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)): - require_user(request, settings, db) + require_admin(request, settings, db) run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all() items = [run_payload(run) for run in run_models] errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all() @@ -392,7 +398,7 @@ def run_detail( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ): - require_user(request, settings, db) + require_admin(request, settings, db) run = db.get(CrawlRun, run_id) if not run: return RedirectResponse("/admin/runs", status_code=303) @@ -457,9 +463,11 @@ def refresh_dismissed( def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse: + user = getattr(request.state, "user", None) payload = { "request": request, - "current_user": getattr(request.state, "user", None), + "current_user": user, + "council_access": can_use_council(user), "csrf_token": getattr(request.state, "csrf_token", ""), "backend_version": BACKEND_VERSION, "frontend_version": FRONTEND_VERSION, diff --git a/app/api.py b/app/api.py index 702b6b5..daa20d5 100644 --- a/app/api.py +++ b/app/api.py @@ -79,7 +79,7 @@ def list_crawl_runs( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: - require_user(request, settings, db) + require_admin(request, settings, db) runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all() return {"items": [run_payload(run) for run in runs]} @@ -90,7 +90,7 @@ def latest_crawl_run( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: - require_user(request, settings, db) + require_admin(request, settings, db) running = get_running_run(db) latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1)) return {"running": run_payload(running), "latest": run_payload(latest)} @@ -103,7 +103,7 @@ def get_crawl_run( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: - require_user(request, settings, db) + require_admin(request, settings, db) run = db.get(CrawlRun, run_id) if not run: return {"error": "not_found"} @@ -136,8 +136,8 @@ def stats( db: Session = Depends(get_db), settings: Settings = Depends(get_settings), ) -> dict: - require_user(request, settings, db) - return stats_payload(db) + user = require_user(request, settings, db) + return stats_payload(db, include_runs=user.role == "admin") def _employee_summary(employee: Employee) -> dict: diff --git a/app/main.py b/app/main.py index 09a839d..c6631c6 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from app.users import router as users_router from app.db import init_db from app.version import BACKEND_VERSION -app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION) +app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION, openapi_url=None) app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(api_router) app.include_router(users_router) diff --git a/app/models.py b/app/models.py index a3a21f3..ba130d0 100644 --- a/app/models.py +++ b/app/models.py @@ -258,7 +258,7 @@ class CouncilCorrections(Base): class SystemUser(Base): __tablename__ = "system_users" - __table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),) + __table_args__ = (CheckConstraint("role IN ('admin', 'user', 'council')", name="ck_system_users_role"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) diff --git a/app/security.py b/app/security.py index 3b84852..fa1a800 100644 --- a/app/security.py +++ b/app/security.py @@ -165,6 +165,17 @@ def require_user(request: Request, settings: Settings, db: Session, *, allow_pas return user +USER_ROLES = { + 'user': 'Пользователь', + 'council': 'Пользователь диссовета', + 'admin': 'Администратор', +} + + +def can_use_council(user: SystemUser | None) -> bool: + return user is not None and user.role in {'admin', 'council'} + + def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser: user = require_user(request, settings, db) if user.role != 'admin': diff --git a/app/services/admin_data.py b/app/services/admin_data.py index 875c8d2..0021af7 100644 --- a/app/services/admin_data.py +++ b/app/services/admin_data.py @@ -213,11 +213,9 @@ def list_employees_page( } -def stats_payload(db: Session) -> dict[str, Any]: - latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1)) - running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1)) +def stats_payload(db: Session, *, include_runs: bool = True) -> dict[str, Any]: latest_added = db.scalar(select(Employee).order_by(desc(Employee.first_seen_at)).limit(1)) - return { + counts = { "total": db.scalar(select(func.count()).select_from(Employee)) or 0, "active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0, "verification_required": db.scalar( @@ -225,11 +223,14 @@ def stats_payload(db: Session) -> dict[str, Any]: ) or 0, "dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0, - "new_in_last_run": latest_run.new_count if latest_run else 0, "latest_added": employee_display_payload(latest_added) if latest_added else None, - "latest_run": run_payload(latest_run) if latest_run else None, - "current_running_run": run_payload(running_run) if running_run else None, } + if include_runs: + latest_run = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1)) + running_run = db.scalar(select(CrawlRun).where(CrawlRun.status == "running").order_by(desc(CrawlRun.started_at)).limit(1)) + counts.update(new_in_last_run=latest_run.new_count if latest_run else 0, + latest_run=run_payload(latest_run), current_running_run=run_payload(running_run)) + return counts def run_payload(run: CrawlRun | None) -> dict[str, Any] | None: diff --git a/app/static/admin.css b/app/static/admin.css index 0f0197a..904cb75 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -67,6 +67,11 @@ outline-offset: 2px; } +:is(.directory__filters, .council__search) :is(input, select):focus { + outline: 2px solid #0f766e; + outline-offset: 0; +} + .admin__main { flex: 1; margin: 28px var(--admin-gutter); diff --git a/app/static/council.js b/app/static/council.js index 06acf4b..f8c18d5 100644 --- a/app/static/council.js +++ b/app/static/council.js @@ -8,8 +8,7 @@ if (names.includes(previous)) branch.value = previous; }); const dialog = document.querySelector('[data-council-dialog]'); - if (!dialog) return; - const form = dialog.querySelector('[data-edit-form]'); + const form = dialog?.querySelector('[data-edit-form]'); const autoSpecialties = form?.querySelector('[data-specialty-auto]'); const specialtyCheckboxes = form ? Array.from(form.querySelectorAll('[name="specialty_codes"]')) : []; autoSpecialties?.addEventListener('change', () => { @@ -149,6 +148,7 @@ }, true); window.addEventListener('resize', repositionTooltips); + if (!dialog) return; dialog.querySelectorAll('[data-edit-close]').forEach(button => { button.addEventListener('click', () => { if (!saving) dialog.close(); }); }); diff --git a/app/static/guide.js b/app/static/guide.js index 3a93d27..d7b59fc 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -5,9 +5,11 @@ const directory = '/admin/directory'; const council = '/admin/council'; const publications = '/admin/council/publications'; + const councilAccess = guide.dataset.guideCouncil === 'true'; const steps = [ {id: 'welcome', title: 'Познакомьтесь с Workers', - intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'}, + intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. ' + : 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'}, {id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [ ['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'], ['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'], @@ -70,7 +72,7 @@ ['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'], ['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'], ]}, - ]; + ].filter(step => councilAccess || (step.path !== council && step.path !== publications)); const main = document.querySelector('#main-content'); const trigger = document.querySelector('[data-guide-open]'); diff --git a/app/templates/account.html b/app/templates/account.html index ab91967..f7fa217 100644 --- a/app/templates/account.html +++ b/app/templates/account.html @@ -3,7 +3,7 @@ {% block content %}

Личный кабинет

-

{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}

+

{{ account_user.full_name or account_user.username }} · {{ user_roles[account_user.role] }}

Первый вход
{{ account_user.first_login_at|admin_datetime }}
Последний вход
{{ account_user.last_login_at|admin_datetime }}
Пароль изменён
{{ account_user.password_changed_at|admin_datetime }}
{% if password_changed %}

Пароль изменён. Другие сессии завершены.

{% endif %}
diff --git a/app/templates/base.html b/app/templates/base.html index 1d74b3a..a29e7b6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,3 +1,4 @@ +{% set is_admin = current_user and current_user.role == 'admin' %} @@ -17,9 +18,11 @@ {% else %} Обзор Сотрудники - Диссертационный совет + {% if council_access %}Диссертационный совет{% endif %} + {% if is_admin %} Запуски - {% if current_user and current_user.role == 'admin' %}Пользователи{% endif %} + Пользователи + {% endif %} Личный кабинет {% if current_user and not current_user.must_change_password %}Как пользоваться{% endif %}
diff --git a/app/templates/council_candidate_articles.html b/app/templates/council_candidate_articles.html index e20699b..458eb3d 100644 --- a/app/templates/council_candidate_articles.html +++ b/app/templates/council_candidate_articles.html @@ -7,9 +7,11 @@ {% if candidate.ambiguous %}

Есть сотрудники с одинаковым ФИО. Публикации из данных ВШЭ не присвоены автоматически; показаны записи из личного кабинета этого сотрудника.

{% endif %}
    {% for article in candidate.articles %} -
  1. +
  2. + {% if can_edit %} {% set employee_id = candidate.employee_id %} {% include "council_edit_button.html" %} + {% endif %}
    {{ article.title }} {{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %} diff --git a/app/templates/council_edit_dialog.html b/app/templates/council_edit_dialog.html index 482b2e3..27989c4 100644 --- a/app/templates/council_edit_dialog.html +++ b/app/templates/council_edit_dialog.html @@ -1,10 +1,10 @@ +{% if can_edit %}

    Редактирование статьи

    - {% if can_edit %}
    {% for name in journal_names|default([]) %} - {% else %} -

    Для редактирования статей и квартилей войдите в систему.

    - Войти в систему - {% endif %}
    +{% endif %} diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html index c89f878..0a42728 100644 --- a/app/templates/council_publications.html +++ b/app/templates/council_publications.html @@ -18,11 +18,10 @@ {% else %}

    Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}

    - {% if not can_edit %}

    Просмотр доступен без входа. Для сохранения изменений войдите в систему.

    {% endif %}
      {% for article in articles %} -
    1. - {% set employee_id = article.employee_id %}{% include "council_edit_button.html" %} +
    2. + {% if can_edit %}{% set employee_id = article.employee_id %}{% include "council_edit_button.html" %}{% endif %}

      {{ article.employee_name }}

      {{ article.title }}{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}

      {{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}

      diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index a493fa2..3fa893f 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -5,7 +5,7 @@ Всего в базе{{ counts.total }} Работают{{ counts.active }} Требуют проверки{{ counts.verification_required }} - Новые за запуск{{ counts.new_in_last_run }} + {% if is_admin %}Новые за запуск{{ counts.new_in_last_run }}{% endif %} Уволены{{ counts.dismissed }}
      @@ -17,6 +17,7 @@ Сотрудников пока нет {% endif %} + {% if is_admin %} Запуски {{ counts.runs }} @@ -25,19 +26,21 @@ Ошибки {{ counts.errors }} + {% endif %}
      +{% if is_admin %}

      Прогресс парсинга

      - {% if current_user and current_user.role == 'admin' %}
      + -
      {% endif %} - {% if current_user and current_user.role == 'admin' %}
      +
      +
      -
      {% endif %} +
      {% set run = counts.current_running_run or latest_run %} @@ -66,6 +69,7 @@
      +{% endif %} {% endblock %} {% block scripts %} diff --git a/app/templates/guide_content.html b/app/templates/guide_content.html index eac82da..1de565d 100644 --- a/app/templates/guide_content.html +++ b/app/templates/guide_content.html @@ -1,13 +1,14 @@

      Выберите задачу

      -

      Сайт помогает найти сотрудника, изучить его профиль и публикации, подобрать кандидатов в диссертационный совет.

      +

      Сайт помогает найти сотрудника и изучить его профиль{% if council_access %}, а также подобрать кандидатов в диссертационный совет{% endif %}.

      Сотрудники
      Поиск по ФИО или ссылке на профиль, контакты, должности и учёные степени.
      -
      Диссертационный совет
      Подбор по специальности ВАК, отрасли оценки и публикационным показателям.
      -
      Обзор и Запуски
      Количество сотрудников, состояние загрузки данных и история её запусков.
      + {% if council_access %}
      Диссертационный совет
      Подбор по специальности ВАК, отрасли оценки и публикационным показателям.
      {% endif %} +
      Обзор
      Количество сотрудников и их статус.
      + {% if is_admin %}
      Запуски
      Состояние загрузки данных и история запусков.
      {% endif %}
      Личный кабинет
      Смена пароля и история ваших действий. «Выйти» завершает все сессии учётной записи.
      - {% if current_user.role == 'admin' %}

      Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».

      {% else %}

      Вы можете просматривать данные и исправлять сведения о публикациях. Запуск загрузки данных и управление пользователями доступны администратору.

      {% endif %} + {% if is_admin %}

      Как администратор, вы также можете запускать обновление данных и управлять доступом в разделе «Пользователи».

      {% endif %}

      Подсказки можно закрыть в любой момент и снова открыть через «Как пользоваться».

      @@ -23,6 +24,7 @@

      Откройте ФИО в таблице: в карточке собраны должности, статус, контакты, внешние идентификаторы, новости и доступные разделы профиля — образование, степени, публикации, курсы и выпускные работы. Состав зависит от данных источника.

      «Год начала» — начало работы в ВШЭ. «Впервые найден» и «Последний раз найден» — наблюдения системы. «Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.

      +{% if council_access %}

      Подберите кандидатов в диссовет

      @@ -70,3 +72,4 @@

      Практический порядок: выберите специальность и отрасль → примените фильтры → откройте сотрудника → проверьте основания работ → скачайте XLSX. Свои изменения можно проверить в «Личном кабинете».

      +{% endif %} diff --git a/app/templates/guide_walkthrough.html b/app/templates/guide_walkthrough.html index e5f41eb..a6e5c14 100644 --- a/app/templates/guide_walkthrough.html +++ b/app/templates/guide_walkthrough.html @@ -1,4 +1,4 @@ -