Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
268
scripts/smoke_student_dds_browser.py
Normal file
268
scripts/smoke_student_dds_browser.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Live-browser acceptance of trainee KIO -> instructor review -> DDS status.
|
||||
|
||||
Only use with a disposable loopback stack/database. The script creates unique
|
||||
accounts and one group, then removes every row it created in a finally block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
|
||||
def require_loopback(value: str, name: str) -> None:
|
||||
if urlsplit(value).hostname not in {"127.0.0.1", "localhost", "::1"}:
|
||||
raise ValueError(f"{name} must use loopback")
|
||||
|
||||
|
||||
async def login(page, base: str, user: str, password: str, target: str) -> None:
|
||||
await page.goto(f"{base}{target}", wait_until="domcontentloaded")
|
||||
login_heading = page.get_by_role("heading", name="Учебный симулятор системы-112")
|
||||
if await login_heading.is_visible():
|
||||
await page.get_by_label("Логин").fill(user)
|
||||
await page.get_by_label("Пароль").fill(password)
|
||||
await page.get_by_role("button", name="Войти", exact=True).click()
|
||||
await page.get_by_role("heading", name={"/profile": "Профиль курсанта", "/instructor": "Рабочее место преподавателя"}[target]).wait_for()
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> int:
|
||||
require_loopback(args.frontend_url, "frontend-url")
|
||||
require_loopback(args.database_url, "database-url")
|
||||
|
||||
from app.api.auth import hash_password
|
||||
from app.db.models import AuditLog, Group, Scenario as ScenarioRow, ScenarioSubmission, Session, Trainee, User
|
||||
from app.domain import ekp
|
||||
from app.scenarios import store
|
||||
|
||||
scenarios = store.load_from_disk(args.scenarios)
|
||||
source = next((item for item in scenarios if item.id == args.source_scenario), None)
|
||||
if source is None or source.ground_truth.incident_code is None or source.ground_truth.dds is None:
|
||||
raise ValueError(f"source scenario must be a classified DDS case: {args.source_scenario}")
|
||||
incident_group = ekp.incident(source.ground_truth.incident_code).group
|
||||
service = source.ground_truth.dds.value
|
||||
suffix = secrets.token_hex(5)
|
||||
teacher_login = f"browser-i-{suffix}"
|
||||
trainee_login = f"browser-t-{suffix}"
|
||||
teacher_password = secrets.token_urlsafe(24)
|
||||
trainee_password = secrets.token_urlsafe(24)
|
||||
teacher_name = f"Browser instructor {suffix}"
|
||||
trainee_name = f"Browser trainee {suffix}"
|
||||
group = Group(name=f"browser-smoke-{suffix}", owner_login=teacher_login)
|
||||
trainee = Trainee(name=trainee_name)
|
||||
session_ids: list[UUID] = []
|
||||
submission_id: UUID | None = None
|
||||
published_scenario_id: str | None = None
|
||||
session_id: UUID | None = None
|
||||
result: dict = {"checks": {}, "source_scenario": source.id}
|
||||
engine = create_async_engine(args.database_url, pool_pre_ping=True)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with factory() as db:
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
trainee.group_id = group.id
|
||||
db.add(trainee)
|
||||
await db.flush()
|
||||
db.add_all([
|
||||
User(login=teacher_login, full_name=teacher_name,
|
||||
password_hash=hash_password(teacher_password), role="instructor", blocked=False),
|
||||
User(login=trainee_login, full_name=trainee_name,
|
||||
password_hash=hash_password(trainee_password), role="trainee",
|
||||
trainee_id=trainee.id, service=service, blocked=False),
|
||||
])
|
||||
await db.commit()
|
||||
|
||||
async with async_playwright() as playwright:
|
||||
browser = await playwright.chromium.launch(headless=True)
|
||||
student_page = await browser.new_page(ignore_https_errors=True)
|
||||
await login(student_page, args.frontend_url, trainee_login, trainee_password, "/profile")
|
||||
|
||||
title = f"КИО из браузера {suffix}"
|
||||
address = f"Москва, учебная улица, дом {suffix[:2]}"
|
||||
description = "В учебном помещении виден дым, требуется проверка и выезд службы."
|
||||
await student_page.get_by_label("Название").fill(title)
|
||||
await student_page.locator('input[aria-label="Адрес"]').fill(address)
|
||||
await student_page.locator('textarea[aria-label="Описание"]').fill(description)
|
||||
await student_page.get_by_label("Категория происшествия").select_option(value=str(incident_group))
|
||||
await student_page.get_by_role("group", name="Что случилось?").get_by_role(
|
||||
"button", name="Пожар", exact=True
|
||||
).click()
|
||||
for index, sign in enumerate(source.signs, start=1):
|
||||
picker = student_page.get_by_label(f"Признак происшествия {index}")
|
||||
await picker.wait_for(state="visible")
|
||||
await picker.select_option(label=sign)
|
||||
await student_page.get_by_role("button", name="Сохранить черновик преподавателю").click()
|
||||
await student_page.get_by_text("Черновик КИО отправлен и ожидает проверки.", exact=True).wait_for()
|
||||
result["checks"]["student_submitted_kio_from_profile"] = True
|
||||
|
||||
async with factory() as db:
|
||||
deadline = time.monotonic() + 10
|
||||
submission = None
|
||||
while time.monotonic() < deadline:
|
||||
submission = await db.scalar(select(ScenarioSubmission).where(ScenarioSubmission.title == title))
|
||||
if submission is not None:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
if submission is None:
|
||||
raise RuntimeError("browser-submitted KIO was not persisted in PostgreSQL")
|
||||
submission_id = submission.id
|
||||
|
||||
instructor_page = await browser.new_page(ignore_https_errors=True)
|
||||
await login(instructor_page, args.frontend_url, teacher_login, teacher_password, "/instructor")
|
||||
proposal = instructor_page.locator(".student-scenario-proposal").filter(has_text=title)
|
||||
await proposal.wait_for(state="visible")
|
||||
await proposal.get_by_role("button", name="Проверил(а), утвердить в банк ДДС").click()
|
||||
await proposal.wait_for(state="hidden")
|
||||
result["checks"]["instructor_approved_in_browser"] = True
|
||||
|
||||
await instructor_page.get_by_label("Источник карточек").select_option(label="Сформированные курсантами")
|
||||
scenario_select = instructor_page.get_by_label("Сценарий занятия")
|
||||
await scenario_select.wait_for()
|
||||
deadline = time.monotonic() + 10
|
||||
selected_value = None
|
||||
while time.monotonic() < deadline:
|
||||
options = await scenario_select.locator("option").all()
|
||||
for option in options:
|
||||
if title in (await option.text_content() or ""):
|
||||
selected_value = await option.get_attribute("value")
|
||||
break
|
||||
if selected_value:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
if not selected_value:
|
||||
raise RuntimeError("approved trainee KIO did not enter instructor's DDS scenario bank")
|
||||
published_scenario_id = selected_value
|
||||
await scenario_select.select_option(value=selected_value)
|
||||
trainee_select = instructor_page.get_by_label("Учётная запись курсанта")
|
||||
trainee_options = await trainee_select.locator("option").all()
|
||||
trainee_value = None
|
||||
for option in trainee_options:
|
||||
if trainee_name in (await option.text_content() or ""):
|
||||
trainee_value = await option.get_attribute("value")
|
||||
break
|
||||
if not trainee_value:
|
||||
raise RuntimeError("temporary trainee account is missing from instructor's trainee list")
|
||||
await trainee_select.select_option(value=trainee_value)
|
||||
await instructor_page.get_by_role("button", name="Начать занятие").click()
|
||||
await instructor_page.wait_for_function("new URL(location.href).searchParams.has('session')", timeout=15000)
|
||||
session_id = UUID(urlsplit(instructor_page.url).query.split("session=", 1)[1].split("&", 1)[0])
|
||||
session_ids.append(session_id)
|
||||
await instructor_page.get_by_role("link", name=re.compile("открыть ДДС")).wait_for()
|
||||
result["checks"]["instructor_started_dds_lesson"] = True
|
||||
|
||||
await student_page.goto(f"{args.frontend_url}/dds?session={session_id}&role=dds_{service}", wait_until="domcontentloaded")
|
||||
await student_page.locator(".dds-connection-live").filter(has_text="на связи").wait_for(timeout=15000)
|
||||
queue_row = student_page.locator("tr").filter(has_text=title)
|
||||
await queue_row.wait_for(state="visible", timeout=15000)
|
||||
await queue_row.get_by_role("button", name="Открыть карточку").click()
|
||||
await student_page.locator(".dds-case-address").get_by_text(address, exact=True).wait_for()
|
||||
result["checks"]["approved_kio_received_in_live_dds"] = True
|
||||
|
||||
service_buttons = student_page.locator(".dds-service-recipient-list .dds-service-recipient")
|
||||
await service_buttons.first.wait_for(state="visible")
|
||||
result["recipient_buttons"] = await service_buttons.all_text_contents()
|
||||
managed_button = None
|
||||
for index in range(await service_buttons.count()):
|
||||
candidate = service_buttons.nth(index)
|
||||
if await candidate.is_enabled():
|
||||
managed_button = candidate
|
||||
break
|
||||
if managed_button is None:
|
||||
raise RuntimeError(f"no manageable DDS service button: {result['recipient_buttons']}")
|
||||
managed_button_text = await managed_button.locator("b").inner_text()
|
||||
await managed_button.click()
|
||||
classification = await student_page.locator(".dds-case-classification").inner_text()
|
||||
result["actual_managed_service_button"] = " ".join(managed_button_text.split())
|
||||
result["service_assignment_matches_card"] = result["actual_managed_service_button"] in classification
|
||||
if not result["service_assignment_matches_card"]:
|
||||
raise RuntimeError("enabled managed-service button differs from the service shown on the card")
|
||||
await student_page.locator(".dds-case-drawer-status").wait_for(state="visible", timeout=5000)
|
||||
status_controls = student_page.locator('select[aria-label^="Следующий статус "]')
|
||||
if await status_controls.count() == 0:
|
||||
raise RuntimeError("DDS status pane opened without a status control: " +
|
||||
(await student_page.locator("body").inner_text())[-1600:])
|
||||
status_select = status_controls.first
|
||||
await status_select.select_option(label="Принята")
|
||||
await student_page.get_by_label("Основание статуса").fill("Карточка получена и принята ДДС.")
|
||||
await student_page.get_by_label("Полученные сведения").fill("Направление реагирования подтверждено.")
|
||||
await student_page.get_by_role("button", name="Сохранить статус").click()
|
||||
await student_page.locator(".dds-status-history").get_by_text("Принята", exact=False).first.wait_for()
|
||||
result["checks"]["dds_primary_status_saved"] = True
|
||||
|
||||
crew_select = student_page.get_by_label(f"Наряд {service}")
|
||||
crew_options = await crew_select.locator("option").all()
|
||||
crew_value = None
|
||||
for option in crew_options:
|
||||
candidate = await option.get_attribute("value")
|
||||
if candidate:
|
||||
crew_value = candidate
|
||||
break
|
||||
if crew_value:
|
||||
await crew_select.select_option(value=crew_value)
|
||||
await status_select.select_option(label="Начало реагирования")
|
||||
await student_page.get_by_label("Основание статуса").fill("Получен доклад о начале реагирования.")
|
||||
await student_page.get_by_label("Полученные сведения").fill("Бригада выехала к месту происшествия.")
|
||||
await student_page.get_by_role("button", name="Сохранить статус").click()
|
||||
history = student_page.locator(".dds-status-history")
|
||||
await history.get_by_text("Начало реагирования", exact=False).first.wait_for()
|
||||
result["checks"]["dds_followup_status_saved"] = True
|
||||
|
||||
if args.output:
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
await student_page.screenshot(path=args.output, full_page=True)
|
||||
result["session_id"] = str(session_id)
|
||||
result["scenario_id"] = published_scenario_id
|
||||
result["managed_service"] = service
|
||||
result["all_checks_passed"] = all(result["checks"].values())
|
||||
await browser.close()
|
||||
|
||||
rendered = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
print(rendered)
|
||||
if args.json_output:
|
||||
Path(args.json_output).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.json_output).write_text(rendered + "\n", encoding="utf-8")
|
||||
return 0 if result["all_checks_passed"] else 1
|
||||
finally:
|
||||
async with factory() as db:
|
||||
await db.execute(delete(Session).where(or_(
|
||||
Session.trainee_id == trainee.id,
|
||||
Session.id.in_(session_ids) if session_ids else False,
|
||||
)))
|
||||
if submission_id is not None:
|
||||
await db.execute(delete(ScenarioSubmission).where(ScenarioSubmission.id == submission_id))
|
||||
if published_scenario_id:
|
||||
await db.execute(delete(ScenarioRow).where(ScenarioRow.id == published_scenario_id))
|
||||
await db.execute(delete(AuditLog).where(AuditLog.actor.in_([teacher_login, trainee_login])))
|
||||
await db.execute(delete(User).where(User.login.in_([teacher_login, trainee_login])))
|
||||
await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
|
||||
await db.execute(delete(Group).where(Group.name == group.name))
|
||||
await db.commit()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frontend-url", required=True)
|
||||
parser.add_argument("--database-url", required=True)
|
||||
parser.add_argument("--scenarios", type=Path, default=ROOT / "scenarios")
|
||||
parser.add_argument("--source-scenario", default="t01-1-fire-container")
|
||||
parser.add_argument("--output", help="optional DDS screenshot path")
|
||||
parser.add_argument("--json-output", help="optional machine-readable evidence path")
|
||||
raise SystemExit(asyncio.run(main(parser.parse_args())))
|
||||
Loading…
Reference in a new issue