From 508aae4f8a7f0eba55769581b23864f4be2a2aae Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 12 Nov 2026 09:35:26 +0000 Subject: [PATCH] feat: add council user role and restrict crawl runs --- README.md | 12 +++-- app/admin.py | 28 +++++++----- app/api.py | 10 ++--- app/main.py | 2 +- app/models.py | 2 +- app/security.py | 11 +++++ app/services/admin_data.py | 15 ++++--- app/static/admin.css | 5 +++ app/static/guide.js | 6 ++- app/templates/account.html | 2 +- app/templates/base.html | 7 ++- app/templates/dashboard.html | 14 +++--- app/templates/guide_content.html | 11 +++-- app/templates/guide_walkthrough.html | 4 +- app/templates/user_activity.html | 2 +- app/templates/users.html | 4 +- app/users.py | 8 ++-- app/version.py | 6 +-- migrations/014_system_user_council_role.sql | 6 +++ .../sqlite/014_system_user_council_role.sql | 31 +++++++++++++ pyproject.toml | 2 +- tests/browser_admin.py | 7 +++ tests/browser_users.py | 8 +++- tests/test_admin_templates.py | 4 +- tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_db_schema.py | 27 ++++++++++++ tests/test_users.py | 44 ++++++++++++++++--- 28 files changed, 217 insertions(+), 65 deletions(-) create mode 100644 migrations/014_system_user_council_role.sql create mode 100644 migrations/sqlite/014_system_user_council_role.sql diff --git a/README.md b/README.md index e2534b8..d26e1ac 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`. Перед запуском версии 2.0.0 на существующей 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/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] }}

{% 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/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 @@ -