Compare commits

...
6 Commits
Author SHA1 Message Date
Ian Roddis cf8d72f73e Making OData Refresh a background process 2026-08-05 16:26:52 -03:00
Ian Roddis 2e7976e382 Rejigging async jobs 2026-08-05 16:21:31 -03:00
Ian Roddis 2a0b8fcab2 Fixing errors and danger zone 2026-08-05 14:19:25 -03:00
Ian Roddis 392b79e801 Fixing presentation of objective thread 2026-08-05 14:15:10 -03:00
Ian Roddis 3ebe9a0894 Fixing imports 2026-08-05 13:59:57 -03:00
Ian Roddis 8d37b2ad67 Fixing column names, fixing model number 2026-08-05 13:35:09 -03:00
19 changed files with 783 additions and 126 deletions

No files matched your search

+4 -1
View File
@@ -34,7 +34,10 @@
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000__TRACKED_VAR__)", "Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000__TRACKED_VAR__)",
"Bash(sqlite3 app.db \"PRAGMA foreign_keys=ON; SELECT scope_model_id FROM augmented_parts WHERE id=1862;\")", "Bash(sqlite3 app.db \"PRAGMA foreign_keys=ON; SELECT scope_model_id FROM augmented_parts WHERE id=1862;\")",
"Bash(sqlite3 app.db \"SELECT id, name FROM scope_models WHERE name='Viper';\")", "Bash(sqlite3 app.db \"SELECT id, name FROM scope_models WHERE name='Viper';\")",
"Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")" "Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")",
"Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)"
] ]
} }
} }
@@ -0,0 +1,44 @@
"""add jobs table
Revision ID: 04425ce3d4d5
Revises: a3f7c19b2d40
Create Date: 2026-08-05 15:21:09.527442
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "04425ce3d4d5"
down_revision: Union[str, None] = "a3f7c19b2d40"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"jobs",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("kind", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("jobs")
# ### end Alembic commands ###
+2
View File
@@ -14,6 +14,7 @@ from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread from app.models.iso_thread import IsoThread
from app.models.job import Job
from app.models.manufacturer import Manufacturer from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour from app.models.part_colour import PartColour
from app.models.part_type import PartType from app.models.part_type import PartType
@@ -40,6 +41,7 @@ __all__ = [
"ImportedPart", "ImportedPart",
"ImportedPartConfig", "ImportedPartConfig",
"IsoThread", "IsoThread",
"Job",
"Manufacturer", "Manufacturer",
"PartColour", "PartColour",
"PartType", "PartType",
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Job(Base):
"""Tracks a long-running admin operation (sync, import, fit recompute).
Persisted (rather than kept in a module-level dict) so that any worker
process/replica handling a status-poll request can see progress recorded
by whichever process is actually running the job.
"""
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
kind: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running"
) # running | done | error
message: Mapped[str] = mapped_column(Text, nullable=False, default="Working…")
result: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
+54
View File
@@ -0,0 +1,54 @@
"""Irreversible bulk-delete of all domain (parts/taxonomy/fit) data.
Leaves users, roles/permissions, refresh tokens, the audit log, and the
ImportedPartConfig (OData connection settings) untouched.
"""
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitNote
from app.models.imported_part import ImportedPart
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.unc_thread import UncThread
# Children before the RESTRICT-protected reference tables they point to.
_TARGETS = [
("Fit notes", FitNote),
("Fits", Fit),
("Augmented part notes", AugmentedPartNote),
("Scope info", ScopeInfo),
("Attachment points", AttachmentPoint),
("Augmented part / imported part links", augmented_part_imported_parts),
("Augmented parts", AugmentedPart),
("Imported parts", ImportedPart),
("Scope models", ScopeModel),
("Attachment point names", AttachmentPointName),
("ISO threads", IsoThread),
("UNC threads", UncThread),
("Clamp connections", ClampConnection),
("Static connections", StaticConnection),
("Manufacturers", Manufacturer),
]
async def purge_domain_data(db: AsyncSession) -> dict[str, int]:
"""Delete every row from every domain table, in FK-safe order.
Returns a label -> rows-deleted map for reporting/audit purposes.
"""
counts: dict[str, int] = {}
for label, target in _TARGETS:
result = await db.execute(delete(target))
counts[label] = result.rowcount or 0
await db.commit()
return counts
+74 -52
View File
@@ -1,11 +1,12 @@
"""Lightweight in-process tracking for long-running admin operations. """DB-backed tracking for long-running admin operations.
Jobs live in a module-level dict, so this only works correctly for a single Jobs are persisted to the ``jobs`` table rather than kept in a module-level
OS process (the app's current deployment: one uvicorn worker, no multi-process dict, so that a status-poll request landing on a *different* worker
or multi-replica setup — see ``entrypoint.sh`` / ``docker-compose.yml``). If process/replica than the one running the job can still see its progress —
the app is ever scaled to multiple workers/replicas, this needs to move to required once the app runs with more than one uvicorn worker or replica
shared storage (a DB table, Redis) so a poll request landing on a different (a module-level dict is process-local and would report "not found" for a job
process can still see the job. started elsewhere, even while that job is still running and writing to the
shared database).
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,62 +14,83 @@ from __future__ import annotations
import asyncio import asyncio
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
_MAX_FINISHED_AGE_SECONDS = 3600 from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.models.job import Job
_MAX_FINISHED_AGE = timedelta(hours=1)
@dataclass async def get(db: AsyncSession, job_id: str) -> Job | None:
class Job: return await db.get(Job, job_id)
id: str
kind: str
status: str = "running" # running | done | error
message: str = "Working…"
result: object | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
finished_at: datetime | None = None
_jobs: dict[str, Job] = {} async def start(kind: str, run: Callable[[str], Awaitable[None]]) -> Job:
"""Create a job row, launch ``run(job_id)`` in the background, and return
the job immediately.
``run`` is responsible for calling ``mark_done``/``mark_error`` to record
def get(job_id: str) -> Job | None: the outcome, including on its own errors. Any exception that escapes
return _jobs.get(job_id) anyway is caught here as a last resort so the job doesn't hang forever in
"running" state.
def _evict_stale() -> None:
now = datetime.now(UTC)
stale = [
job_id
for job_id, job in _jobs.items()
if job.finished_at
and (now - job.finished_at).total_seconds() > _MAX_FINISHED_AGE_SECONDS
]
for job_id in stale:
del _jobs[job_id]
def start(kind: str, run: Callable[[Job], Awaitable[None]]) -> Job:
"""Create a job, launch ``run(job)`` in the background, and return it immediately.
``run`` is responsible for updating ``job.status``/``job.message``/``job.result``
to reflect the outcome, including catching its own errors. Any exception that
escapes anyway is caught here as a last resort so the job doesn't hang forever
in "running" state.
""" """
_evict_stale() async with AsyncSessionLocal() as db:
job = Job(id=uuid.uuid4().hex, kind=kind) await _evict_stale(db)
_jobs[job.id] = job job = Job(id=uuid.uuid4().hex, kind=kind, status="running", message="Working…")
db.add(job)
await db.commit()
async def _runner() -> None: async def _runner() -> None:
try: try:
await run(job) await run(job.id)
except Exception as exc: except Exception as exc:
job.status = "error" await mark_error(job.id, f"{kind} failed: {exc}")
job.message = f"{kind} failed: {exc}"
finally:
job.finished_at = datetime.now(UTC)
asyncio.create_task(_runner()) asyncio.create_task(_runner())
return job return job
async def mark_done(job_id: str, message: str, result: dict | None = None) -> None:
await _update(job_id, status="done", message=message, result=result)
async def mark_error(job_id: str, message: str) -> None:
await _update(job_id, status="error", message=message)
async def set_message(job_id: str, message: str) -> None:
await _update(job_id, message=message)
async def _update(
job_id: str,
*,
status: str | None = None,
message: str | None = None,
result: dict | None = None,
) -> None:
async with AsyncSessionLocal() as db:
job = await db.get(Job, job_id)
if job is None:
return
if status is not None:
job.status = status
if status in ("done", "error"):
job.finished_at = datetime.now(UTC)
if message is not None:
job.message = message
if result is not None:
job.result = result
await db.commit()
async def _evict_stale(db: AsyncSession) -> None:
cutoff = datetime.now(UTC) - _MAX_FINISHED_AGE
await db.execute(
delete(Job).where(Job.finished_at.is_not(None), Job.finished_at < cutoff)
)
await db.commit()
+11
View File
@@ -7,6 +7,7 @@ the parts of that type that fit the row's scope.
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
from app.crud import fit as fit_crud from app.crud import fit as fit_crud
from app.crud import part_type as part_type_crud from app.crud import part_type as part_type_crud
@@ -14,6 +15,7 @@ from app.crud import scope_info as scope_info_crud
from app.models.fit import FitLabel from app.models.fit import FitLabel
SCOPE_TYPE_NAME = "Scope" SCOPE_TYPE_NAME = "Scope"
OBJECTIVE_ATTACHMENT_POINT_NAME = "Objective"
# "exact" outranks "possible" when the same part fits a scope via several fits. # "exact" outranks "possible" when the same part fits a scope via several fits.
_LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1} _LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1}
@@ -43,6 +45,7 @@ async def build(
{ {
"scope": AugmentedPart, "scope": AugmentedPart,
"info": ScopeInfo | None, "info": ScopeInfo | None,
"objective_attachment_point": AttachmentPoint | None,
"cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]},
}, },
..., ...,
@@ -85,6 +88,13 @@ async def build(
p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids)) p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids))
} }
objective_aps = {
ap.augmented_part_id: ap
for ap in await attachment_point_crud.list_by_augmented_parts_and_name(
db, list(scope_ids), OBJECTIVE_ATTACHMENT_POINT_NAME
)
}
infos = { infos = {
info.augmented_part_id: info info.augmented_part_id: info
for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids)) for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids))
@@ -120,6 +130,7 @@ async def build(
{ {
"scope": scope, "scope": scope,
"info": infos.get(scope.id), "info": infos.get(scope.id),
"objective_attachment_point": objective_aps.get(scope.id),
"cells": cells, "cells": cells,
} }
) )
+10 -25
View File
@@ -140,15 +140,11 @@ def parse_workbook(data: bytes) -> list[dict]:
wb.close() wb.close()
async def _static_ap( async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float):
db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float
):
"""Create an attachment point carrying a StaticConnection for ``od_mm``.""" """Create an attachment point carrying a StaticConnection for ``od_mm``."""
sc = await static_connection_crud.get_by_od_mm(db, od_mm) sc = await static_connection_crud.get_by_od_mm(db, od_mm)
if sc is None: if sc is None:
sc = await static_connection_crud.create( sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH)
db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH
)
apn = await apn_crud.get_by_name(db, label) apn = await apn_crud.get_by_name(db, label)
if apn is None: if apn is None:
apn = await apn_crud.create(db, name=label) apn = await apn_crud.create(db, name=label)
@@ -160,9 +156,7 @@ async def _static_ap(
) )
async def _thread_ap( async def _thread_ap(db: AsyncSession, *, augmented_part_id: int, label: str, spec: str):
db: AsyncSession, *, augmented_part_id: int, label: str, spec: str
):
"""Create an attachment point for a parsed ISO/UNC thread, or None.""" """Create an attachment point for a parsed ISO/UNC thread, or None."""
parsed = _parse_thread_attribute(spec) parsed = _parse_thread_attribute(spec)
if parsed is None: if parsed is None:
@@ -206,8 +200,7 @@ async def _link_fit(
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP.""" """Create a POSSIBLE fit from the scope's AP to the referenced part's first AP."""
if scope_ap is None: if scope_ap is None:
messages.append( messages.append(
f"{scope_role}_fit '{target_sku}' skipped: scope has no " f"{scope_role}_fit '{target_sku}' skipped: scope has no {scope_role} attachment point"
f"{scope_role} attachment point"
) )
return return
target = await augmented_part_crud.get_by_sku(db, target_sku) target = await augmented_part_crud.get_by_sku(db, target_sku)
@@ -220,16 +213,13 @@ async def _link_fit(
target_aps = await attachment_point_crud.list_for_augmented_part(db, target.id) target_aps = await attachment_point_crud.list_for_augmented_part(db, target.id)
if not target_aps: if not target_aps:
messages.append( messages.append(
f"{scope_role}_fit '{target_sku}' skipped: referenced part has no " f"{scope_role}_fit '{target_sku}' skipped: referenced part has no attachment points"
"attachment points"
) )
return return
target_ap = target_aps[0] target_ap = target_aps[0]
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap) existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
if existing is None: if existing is None:
await fit_crud.create( await fit_crud.create(db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE)
db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE
)
async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult: async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult:
@@ -251,7 +241,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
# +2: 1-based, and the header occupies row 1. # +2: 1-based, and the header occupies row 1.
row_num = offset + 2 row_num = offset + 2
model_suffix = _text(row.get("model_suffix")) model_suffix = _text(row.get("model_suffix"))
default_product_number = f"{_text(row.get('model'))} {row.get('magnification_min')}-{row.get('magnification_max')}x{row.get('objective_lens')} {row.get('model_suffix')}".strip() default_product_number = f"{_text(row.get('model'))} {row.get('magnification_min')}-{row.get('magnification_max')}x{row.get('objective_lens_diameter_mm')} {row.get('model_suffix')}".strip()
product_number = _text(row.get("product_number")) or default_product_number product_number = _text(row.get("product_number")) or default_product_number
result = ScopeImportRowResult( result = ScopeImportRowResult(
row=row_num, product_number=product_number, status="skipped", messages=[] row=row_num, product_number=product_number, status="skipped", messages=[]
@@ -273,9 +263,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name) manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name)
if manufacturer is None: if manufacturer is None:
manufacturer = await manufacturer_crud.create( manufacturer = await manufacturer_crud.create(db, name=manufacturer_name, url="")
db, name=manufacturer_name, url=""
)
model_name = _text(row.get("model")) model_name = _text(row.get("model"))
url = _text(row.get("url")) url = _text(row.get("url"))
@@ -338,9 +326,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
# Rebuild attachment points from scratch so re-imports stay idempotent # Rebuild attachment points from scratch so re-imports stay idempotent
# (cascades delete any fits that referenced the old points). # (cascades delete any fits that referenced the old points).
for old_ap in await attachment_point_crud.list_for_augmented_part( for old_ap in await attachment_point_crud.list_for_augmented_part(db, part.id):
db, part.id
):
await attachment_point_crud.delete(db, old_ap) await attachment_point_crud.delete(db, old_ap)
ocular_ap = None ocular_ap = None
@@ -366,8 +352,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
) )
if objective_ap is None: if objective_ap is None:
result.messages.append( result.messages.append(
f"could not parse objective_thread " f"could not parse objective_thread '{objective_thread_spec}'"
f"'{objective_thread_spec}'"
) )
staged.append((row, ocular_ap, objective_ap, part.id, result)) staged.append((row, ocular_ap, objective_ap, part.id, result))
+17
View File
@@ -0,0 +1,17 @@
{%- macro connection_spec(ap) -%}
{%- if ap.iso_thread -%}
M{{ ap.iso_thread.diameter_mm }}×{{ ap.iso_thread.pitch_mm }}
{%- elif ap.unc_thread -%}
{{ ap.unc_thread.diameter_fraction }}-{{ ap.unc_thread.tpi }} {{ ap.unc_thread.series.value }}
{%- elif ap.clamp_connection -%}
{%- if ap.clamp_connection.min_mm is not none -%}
{{ ap.clamp_connection.min_mm }}–{{ ap.clamp_connection.max_mm }} mm
{%- else -%}
{{ ap.clamp_connection.min_in }}–{{ ap.clamp_connection.max_in }} in
{%- endif -%}
{%- elif ap.static_connection -%}
{{ ap.static_connection.od_mm }} mm OD
{%- else -%}
—
{%- endif -%}
{%- endmacro -%}
+3
View File
@@ -145,6 +145,9 @@
{% if su or "audit_log:read" in p %} {% if su or "audit_log:read" in p %}
<a href="/admin/audit-log">{{ icon("list-checks") }} Audit Log</a> <a href="/admin/audit-log">{{ icon("list-checks") }} Audit Log</a>
{% endif %} {% endif %}
{% if su %}
<a href="/admin/danger-zone">{{ icon("alert-circle") }} Danger Zone</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
+4 -1
View File
@@ -1,3 +1,4 @@
{% from "_connection_macros.html" import connection_spec %}
{%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} {%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%}
{%- macro magnification_range(info) -%} {%- macro magnification_range(info) -%}
{%- if info and info.magnification_min is not none and info.magnification_max is not none -%} {%- if info and info.magnification_min is not none and info.magnification_max is not none -%}
@@ -45,7 +46,9 @@
<td>{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}</td> <td>{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}</td>
<td>{{ magnification(info) }}</td> <td>{{ magnification(info) }}</td>
<td> <td>
{% if info and info.objective_thread_pitch %} {% if row.objective_attachment_point %}
<code>{{ connection_spec(row.objective_attachment_point) }}</code>
{% elif info and info.objective_thread_pitch %}
{{ info.objective_thread_pitch }} {{ info.objective_thread_pitch }}
{% else %}<span class="muted">—</span>{% endif %} {% else %}<span class="muted">—</span>{% endif %}
</td> </td>
@@ -0,0 +1,14 @@
{% if total %}
<p style="color:var(--color-danger,#c00)">
Purge complete: {{ total }} row(s) deleted.
</p>
<ul class="muted">
{% for label, n in counts.items() %}
{% if n %}
<li>{{ label }}: {{ n }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p class="muted">Nothing to purge — the database was already clean.</p>
{% endif %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Danger Zone · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Danger Zone</h1>
<div class="card" style="border: 1px solid var(--color-danger, #c00)">
<h2>Purge database</h2>
<p>
Permanently deletes every part, attachment point, fit, and imported
part in the database — manufacturers, scope models, ISO/UNC threads,
clamp/static connections, attachment point names, augmented parts,
scope info, attachment points, fits, fit notes, and imported parts.
</p>
<p>
<strong>Not</strong> deleted: users, roles &amp; permissions, refresh
tokens, the audit log, the OData connection configuration, and part
taxonomy (part colours, part types, sub part types).
</p>
<p class="muted">This cannot be undone. Take a database backup first if you might need this data again.</p>
<button class="btn btn-danger"
hx-post="/admin/danger-zone/purge"
hx-target="#purge-result"
hx-swap="innerHTML"
hx-confirm="This will PERMANENTLY delete all parts, taxonomy, attachment points, fits, and imported parts. Users and the OData configuration are kept. This cannot be undone. Continue?">
Purge all part/fit data
</button>
<div id="purge-result" style="margin-top:1rem"></div>
</div>
</main>
{% endblock %}
+1 -3
View File
@@ -29,11 +29,9 @@
<button class="btn btn-primary" <button class="btn btn-primary"
hx-post="/admin/imported-parts/refresh" hx-post="/admin/imported-parts/refresh"
hx-target="#imported-parts-section" hx-target="#imported-parts-section"
hx-swap="outerHTML" hx-swap="outerHTML">
hx-indicator="#refresh-indicator">
Refresh from OData Refresh from OData
</button> </button>
<span id="refresh-indicator" class="htmx-indicator muted"> Loading…</span>
</div> </div>
{% include "admin/_imported_parts_table.html" %} {% include "admin/_imported_parts_table.html" %}
+171 -44
View File
@@ -43,6 +43,7 @@ from app.models.fit import FitLabel
from app.models.unc_thread import UncSeries from app.models.unc_thread import UncSeries
from app.models.user import User from app.models.user import User
from app.services import augmented_part_sync as augmented_part_sync_service from app.services import augmented_part_sync as augmented_part_sync_service
from app.services import danger_zone as danger_zone_service
from app.services import fit_matching as fit_matching_service from app.services import fit_matching as fit_matching_service
from app.services import jobs from app.services import jobs
from app.services import odata as odata_service from app.services import odata as odata_service
@@ -131,6 +132,16 @@ def web_require(*perms: str):
return checker return checker
async def web_require_superuser(user: User = Depends(get_current_web_user)) -> User:
"""Web dependency: superusers only, no permission-string bypass."""
if not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return user
async def _role_perm_ctx(db: AsyncSession) -> dict: async def _role_perm_ctx(db: AsyncSession) -> dict:
all_perms = await role_crud.list_permissions(db) all_perms = await role_crud.list_permissions(db)
scopes = sorted({p.name.split(":")[0] for p in all_perms}) scopes = sorted({p.name.split(":")[0] for p in all_perms})
@@ -1355,6 +1366,19 @@ async def save_imported_part_config(
return HTMLResponse("Configuration saved.", status_code=200) return HTMLResponse("Configuration saved.", status_code=200)
_IMPORTED_PARTS_REFRESH_TARGET_ID = "imported-parts-section"
async def _imported_parts_table_ctx(
db: AsyncSession, *, sync_result: str | None = None, sync_error: str | None = None
) -> dict:
return {
**await _default_imported_parts_table_ctx(db),
"sync_result": sync_result,
"sync_error": sync_error,
}
@router.post("/imported-parts/refresh", response_class=HTMLResponse) @router.post("/imported-parts/refresh", response_class=HTMLResponse)
async def refresh_imported_parts( async def refresh_imported_parts(
request: Request, request: Request,
@@ -1362,45 +1386,94 @@ async def refresh_imported_parts(
actor: User = Depends(web_require("imported_parts:write")), actor: User = Depends(web_require("imported_parts:write")),
): ):
config = await imported_part_config_crud.get(db) config = await imported_part_config_crud.get(db)
async def _imported_table_ctx(sync_result=None, sync_error=None):
return {
**await _default_imported_parts_table_ctx(db),
"sync_result": sync_result,
"sync_error": sync_error,
}
if not config or not config.odata_url: if not config or not config.odata_url:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_imported_parts_table.html", "admin/_imported_parts_table.html",
await _imported_table_ctx(sync_error="OData URL is not configured."), await _imported_parts_table_ctx(
db, sync_error="OData URL is not configured."
),
) )
actor_id = actor.id
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
config = await imported_part_config_crud.get(db)
if not config or not config.odata_url:
await jobs.mark_error(job_id, "OData URL is not configured.")
return
try: try:
records = await odata_service.fetch( records = await odata_service.fetch(
config.odata_url, config.username, config.password config.odata_url, config.username, config.password
) )
created, updated = await imported_part_crud.upsert_bulk(db, records) created, updated = await imported_part_crud.upsert_bulk(db, records)
except Exception as exc:
await jobs.mark_error(job_id, f"Sync failed: {exc}")
return
actor_row = await user_crud.get(db, actor_id)
await audit_crud.log( await audit_crud.log(
db, db,
actor=actor, actor=actor_row,
action="update", action="update",
resource_type="ImportedPart", resource_type="ImportedPart",
resource_id=None, resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)", description=(
) f"OData sync: {created} created, {updated} updated "
return templates.TemplateResponse( f"({len(records)} total records)"
request,
"admin/_imported_parts_table.html",
await _imported_table_ctx(
sync_result=f"Sync complete: {created} created, {updated} updated."
), ),
) )
except Exception as exc: await jobs.mark_done(
job_id,
message=f"Sync complete: {created} created, {updated} updated.",
)
job = await jobs.start("imported_parts_refresh", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _IMPORTED_PARTS_REFRESH_TARGET_ID,
"poll_url": f"/admin/imported-parts/refresh/jobs/{job.id}",
"job": job,
},
)
@router.get("/imported-parts/refresh/jobs/{job_id}", response_class=HTMLResponse)
async def imported_parts_refresh_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("imported_parts:write")),
):
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_imported_parts_table.html", "admin/_imported_parts_table.html",
await _imported_table_ctx(sync_error=f"Sync failed: {exc}"), await _imported_parts_table_ctx(
db, sync_error="Refresh job not found — it may have expired."
),
)
if job.status == "running":
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _IMPORTED_PARTS_REFRESH_TARGET_ID,
"poll_url": f"/admin/imported-parts/refresh/jobs/{job_id}",
"job": job,
},
)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
await _imported_parts_table_ctx(
db,
sync_result=job.message if job.status == "done" else None,
sync_error=job.message if job.status == "error" else None,
),
) )
@@ -1500,13 +1573,12 @@ async def upload_scope_import(
data = await file.read() data = await file.read()
actor_id = actor.id actor_id = actor.id
async def _run(job: jobs.Job) -> None: async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
result = await scope_import_service.import_from_bytes(db, data) result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
job.status = "error" await jobs.mark_error(job_id, f"Could not read spreadsheet: {exc}")
job.message = f"Could not read spreadsheet: {exc}"
return return
actor_row = await user_crud.get(db, actor_id) actor_row = await user_crud.get(db, actor_id)
await audit_crud.log( await audit_crud.log(
@@ -1521,10 +1593,16 @@ async def upload_scope_import(
f"{result.errors} errors" f"{result.errors} errors"
), ),
) )
job.status = "done" await jobs.mark_done(
job.result = result job_id,
message=(
f"Scope import complete: {result.created} created, "
f"{result.updated} updated."
),
result=result.model_dump(mode="json"),
)
job = jobs.start("scope_import", _run) job = await jobs.start("scope_import", _run)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "admin/_job_progress.html", _scope_import_progress_ctx(job) request, "admin/_job_progress.html", _scope_import_progress_ctx(job)
) )
@@ -1534,9 +1612,10 @@ async def upload_scope_import(
async def scope_import_job_status( async def scope_import_job_status(
request: Request, request: Request,
job_id: str, job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")), _: User = Depends(web_require("augmented_parts:write")),
): ):
job = jobs.get(job_id) job = await jobs.get(db, job_id)
if job is None: if job is None:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
@@ -1707,13 +1786,12 @@ async def sync_augmented_parts(
): ):
actor_id = actor.id actor_id = actor.id
async def _run(job: jobs.Job) -> None: async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
result = await augmented_part_sync_service.sync(db) result = await augmented_part_sync_service.sync(db)
except Exception as exc: except Exception as exc:
job.status = "error" await jobs.mark_error(job_id, f"Sync failed: {exc}")
job.message = f"Sync failed: {exc}"
return return
actor_row = await user_crud.get(db, actor_id) actor_row = await user_crud.get(db, actor_id)
await audit_crud.log( await audit_crud.log(
@@ -1727,18 +1805,21 @@ async def sync_augmented_parts(
f"{result.skipped} skipped (no type match)" f"{result.skipped} skipped (no type match)"
), ),
) )
job.status = "done" await jobs.mark_done(
job.message = ( job_id,
f"Sync complete: {result.created} created, {result.deleted} deleted" message=(
f"Sync complete: {result.created} created, "
f"{result.deleted} deleted"
+ ( + (
f", {result.skipped} skipped (type not recognised)" f", {result.skipped} skipped (type not recognised)"
if result.skipped if result.skipped
else "" else ""
) )
+ "." + "."
),
) )
job = jobs.start("augmented_part_sync", _run) job = await jobs.start("augmented_part_sync", _run)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_job_progress.html", "admin/_job_progress.html",
@@ -1757,7 +1838,7 @@ async def augmented_parts_sync_job_status(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")), _: User = Depends(web_require("augmented_parts:write")),
): ):
job = jobs.get(job_id) job = await jobs.get(db, job_id)
if job is None: if job is None:
form_ctx = await _augmented_part_form_ctx(db) form_ctx = await _augmented_part_form_ctx(db)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -2472,7 +2553,7 @@ async def recompute_augmented_part_fits(
): ):
actor_id = actor.id actor_id = actor.id
async def _run(job: jobs.Job) -> None: async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_for_part( result = await fit_matching_service.recompute_for_part(
db, augmented_part_id db, augmented_part_id
@@ -2489,10 +2570,11 @@ async def recompute_augmented_part_fits(
f"{result.created} created, {result.removed} removed" f"{result.created} created, {result.removed} removed"
), ),
) )
job.status = "done" await jobs.mark_done(
job.message = f"{result.created} created, {result.removed} removed." job_id, f"{result.created} created, {result.removed} removed."
)
job = jobs.start("fit_recompute_part", _run) job = await jobs.start("fit_recompute_part", _run)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_job_progress.html", "admin/_job_progress.html",
@@ -2516,7 +2598,7 @@ async def augmented_part_fits_recompute_job_status(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")), actor: User = Depends(web_require("fits:write")),
): ):
job = jobs.get(job_id) job = await jobs.get(db, job_id)
if job is None: if job is None:
ctx = await _augmented_part_fits_ctx( ctx = await _augmented_part_fits_ctx(
db, db,
@@ -2763,7 +2845,7 @@ async def recompute_all_fits(
): ):
actor_id = actor.id actor_id = actor.id
async def _run(job: jobs.Job) -> None: async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_all(db) result = await fit_matching_service.recompute_all(db)
actor_row = await user_crud.get(db, actor_id) actor_row = await user_crud.get(db, actor_id)
@@ -2778,13 +2860,15 @@ async def recompute_all_fits(
f"{result.removed} removed" f"{result.removed} removed"
), ),
) )
job.status = "done" await jobs.mark_done(
job.message = ( job_id,
message=(
f"Recompute complete: {result.created} created, " f"Recompute complete: {result.created} created, "
f"{result.removed} removed." f"{result.removed} removed."
),
) )
job = jobs.start("fit_recompute_all", _run) job = await jobs.start("fit_recompute_all", _run)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_job_progress_inline.html", "admin/_job_progress_inline.html",
@@ -2802,9 +2886,10 @@ async def recompute_all_fits(
async def recompute_all_fits_job_status( async def recompute_all_fits_job_status(
request: Request, request: Request,
job_id: str, job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("fits:write")), _: User = Depends(web_require("fits:write")),
): ):
job = jobs.get(job_id) job = await jobs.get(db, job_id)
if job is None: if job is None:
return HTMLResponse("Recompute job not found — it may have expired.") return HTMLResponse("Recompute job not found — it may have expired.")
if job.status == "running": if job.status == "running":
@@ -3598,3 +3683,45 @@ async def audit_log_page(
"admin/audit_log.html", "admin/audit_log.html",
{"user": user, "entries": await audit_crud.list_recent(db)}, {"user": user, "entries": await audit_crud.list_recent(db)},
) )
# --- Danger Zone ---------------------------------------------------------------
@router.get("/danger-zone", response_class=HTMLResponse)
async def danger_zone_page(
request: Request,
user: User = Depends(web_require_superuser),
):
return templates.TemplateResponse(
request,
"admin/danger_zone.html",
{"user": user},
)
@router.post("/danger-zone/purge", response_class=HTMLResponse)
async def purge_database(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require_superuser),
):
counts = await danger_zone_service.purge_domain_data(db)
total = sum(counts.values())
breakdown = ", ".join(f"{label} {n}" for label, n in counts.items() if n)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Database",
resource_id=None,
description=(
f"Purged all domain data via Danger Zone ({total} row(s) deleted"
+ (f": {breakdown})" if breakdown else ")")
),
)
return templates.TemplateResponse(
request,
"admin/_danger_zone_result.html",
{"total": total, "counts": counts},
)
+88
View File
@@ -2,7 +2,11 @@ import asyncio
import re import re
import pytest import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.core.database import Base
from app.crud import imported_part_config as imported_part_config_crud
from app.crud import role as role_crud from app.crud import role as role_crud
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName from app.models.attachment_point_name import AttachmentPointName
@@ -13,6 +17,25 @@ from tests.test_scope_import import _scope_row, _workbook
_JOB_URL_RE = re.compile(r'hx-get="([^"]+/jobs/[^"]+)"') _JOB_URL_RE = re.compile(r'hx-get="([^"]+/jobs/[^"]+)"')
@pytest_asyncio.fixture
async def db_sessionmaker(tmp_path):
"""Override conftest's in-memory+StaticPool fixture for this module.
These tests run a real background asyncio task (the job) concurrently
with polling requests, both hitting the DB at once. StaticPool pins every
session to one shared raw connection, which can't safely serve two
callers at the same time; a real file-backed DB with its own connection
pool (how SQLite/Postgres actually behave outside tests) can.
"""
db_path = tmp_path / "jobs_test.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
yield maker
await engine.dispose()
async def _web_login(client, email, password="secret123"): async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password}) resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303 assert resp.status_code == 303
@@ -39,6 +62,7 @@ async def test_scope_import_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch client, seeded, make_user, db_sessionmaker, monkeypatch
): ):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
perms = seeded["perms"] perms = seeded["perms"]
editor = await role_crud.create( editor = await role_crud.create(
@@ -69,6 +93,7 @@ async def test_augmented_parts_sync_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch client, seeded, make_user, db_sessionmaker, monkeypatch
): ):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
perms = seeded["perms"] perms = seeded["perms"]
editor = await role_crud.create( editor = await role_crud.create(
@@ -93,6 +118,7 @@ async def test_fit_recompute_for_part_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch client, seeded, make_user, db_sessionmaker, monkeypatch
): ):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
apn = AttachmentPointName(name="Objective") apn = AttachmentPointName(name="Objective")
part_t = PartType(name="Scope") part_t = PartType(name="Scope")
@@ -137,6 +163,7 @@ async def test_fit_recompute_all_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch client, seeded, make_user, db_sessionmaker, monkeypatch
): ):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker) monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
perms = seeded["perms"] perms = seeded["perms"]
editor = await role_crud.create( editor = await role_crud.create(
@@ -154,3 +181,64 @@ async def test_fit_recompute_all_runs_in_background(
final_body = await _poll_until_done(client, match.group(1)) final_body = await _poll_until_done(client, match.group(1))
assert "Recompute complete" in final_body assert "Recompute complete" in final_body
@pytest.mark.asyncio
async def test_imported_parts_refresh_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch
):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async def _fake_fetch(url, username, password):
return [{"InventoryID": "INV-1", "Description": "Widget"}]
monkeypatch.setattr("app.web.admin.odata_service.fetch", _fake_fetch)
async with db_sessionmaker() as db:
await imported_part_config_crud.upsert(
db, odata_url="https://example.test/odata", username=None, password=None
)
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="imported-parts-editor",
permission_ids=[perms["imported_parts:write"].id],
)
editor_id = editor.id
await make_user("refresher@example.com", role_ids=[editor_id])
await _web_login(client, "refresher@example.com")
resp = await client.post("/admin/imported-parts/refresh")
assert resp.status_code == 200
assert 'id="imported-parts-section"' in resp.text
match = _JOB_URL_RE.search(resp.text)
assert match, resp.text
final_body = await _poll_until_done(client, match.group(1))
assert "Sync complete: 1 created, 0 updated." in final_body
assert "INV-1" in final_body
@pytest.mark.asyncio
async def test_imported_parts_refresh_without_config_skips_job(
client, seeded, make_user, db_sessionmaker, monkeypatch
):
monkeypatch.setattr("app.web.admin.AsyncSessionLocal", db_sessionmaker)
monkeypatch.setattr("app.services.jobs.AsyncSessionLocal", db_sessionmaker)
async with db_sessionmaker() as db:
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="imported-parts-editor-noconfig",
permission_ids=[perms["imported_parts:write"].id],
)
editor_id = editor.id
await make_user("refresher2@example.com", role_ids=[editor_id])
await _web_login(client, "refresher2@example.com")
resp = await client.post("/admin/imported-parts/refresh")
assert resp.status_code == 200
assert "OData URL is not configured." in resp.text
# No background job started for this synchronous validation failure.
assert _JOB_URL_RE.search(resp.text) is None
+158
View File
@@ -0,0 +1,158 @@
import pytest
from app.crud import audit as audit_crud
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.unc_thread import UncSeries, UncThread
async def _seed_domain_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
scope_t = PartType(name="Scope")
cover_t = PartType(name="Flip Cover")
apn = AttachmentPointName(name="Objective")
db.add_all([manufacturer, colour, scope_t, cover_t, apn])
await db.flush()
sub_t = SubPartType(name="Riflescope", part_type_id=scope_t.id)
scope_model = ScopeModel(name="Alpha", manufacturer_id=manufacturer.id)
iso = IsoThread(diameter_mm=52.0, pitch_mm=0.75)
unc = UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC)
clamp = ClampConnection(min_mm=25.0, max_mm=30.0)
static = StaticConnection(od_mm=50.0, od_in=1.97)
db.add_all([sub_t, scope_model, iso, unc, clamp, static])
await db.flush()
scope = AugmentedPart(
description="Alpha 3-18x50",
sku="ALP-318",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=scope_t.id,
scope_model_id=scope_model.id,
sub_part_type_id=sub_t.id,
)
cover = AugmentedPart(
description="Flip cover 50mm",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=cover_t.id,
)
db.add_all([scope, cover])
await db.flush()
db.add(AugmentedPartNote(augmented_part_id=scope.id, username="tester", body="note"))
db.add(ScopeInfo(augmented_part_id=scope.id, magnification_min=3, magnification_max=18))
ap_s = AttachmentPoint(
augmented_part_id=scope.id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
ap_c = AttachmentPoint(
augmented_part_id=cover.id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
db.add_all([ap_s, ap_c])
await db.flush()
fit = Fit(
attachment_point_a_id=ap_s.id,
attachment_point_b_id=ap_c.id,
augmented_part_a_id=scope.id,
augmented_part_b_id=cover.id,
label=FitLabel.POSSIBLE,
)
db.add(fit)
await db.flush()
db.add(FitNote(fit_id=fit.id, username="tester", body="fits fine"))
imported = ImportedPart(InventoryID="INV-1", Description="Alpha 3-18x50")
db.add(imported)
config = ImportedPartConfig(id=1, odata_url="https://example.test/odata", username="svc")
db.add(config)
await db.commit()
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_non_superuser_forbidden(client, seeded, make_user):
# "admin" role holds every permission but is not a superuser.
await make_user("admin@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "admin@example.com")
assert (await client.get("/admin/danger-zone")).status_code == 403
assert (await client.post("/admin/danger-zone/purge")).status_code == 403
@pytest.mark.asyncio
async def test_purge_wipes_domain_data_but_keeps_users_and_odata_config(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_domain_data(db)
await make_user("root@example.com", is_superuser=True)
await _web_login(client, "root@example.com")
assert (await client.get("/admin/danger-zone")).status_code == 200
resp = await client.post("/admin/danger-zone/purge")
assert resp.status_code == 200
assert "Purge complete" in resp.text
async with db_sessionmaker() as db:
for Model in (
AugmentedPart,
AugmentedPartNote,
ScopeInfo,
AttachmentPoint,
Fit,
FitNote,
ImportedPart,
ScopeModel,
AttachmentPointName,
IsoThread,
UncThread,
ClampConnection,
StaticConnection,
Manufacturer,
):
rows = (await db.execute(Model.__table__.select())).all()
assert rows == [], f"{Model.__name__} should be empty after purge"
# Kept: users, RBAC, the OData connection config, and part taxonomy.
from app.crud import user as user_crud
assert await user_crud.get_by_email(db, "root@example.com") is not None
config = await db.get(ImportedPartConfig, 1)
assert config is not None
assert config.odata_url == "https://example.test/odata"
for Model in (PartColour, PartType, SubPartType):
rows = (await db.execute(Model.__table__.select())).all()
assert rows != [], f"{Model.__name__} should survive the purge"
entries = await audit_crud.list_recent(db)
assert any("Purged all domain data" in e.description for e in entries)
+65
View File
@@ -1,7 +1,10 @@
import pytest import pytest
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart
from app.models.fit import Fit, FitLabel from app.models.fit import Fit, FitLabel
from app.models.iso_thread import IsoThread
from app.models.part_type import PartType from app.models.part_type import PartType
from app.models.scope_info import ScopeInfo from app.models.scope_info import ScopeInfo
from app.models.sub_part_type import SubPartType from app.models.sub_part_type import SubPartType
@@ -214,6 +217,68 @@ async def test_build_includes_objective_lens_diameter(db_sessionmaker):
assert rows["Zeta 1-6x24"]["info"] is None assert rows["Zeta 1-6x24"]["info"] is None
@pytest.mark.asyncio
async def test_build_prefers_objective_attachment_point_over_scope_info(
db_sessionmaker,
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
apn = AttachmentPointName(name="Objective")
db.add(apn)
await db.flush()
iso = IsoThread(diameter_mm=44, pitch_mm=0.5)
db.add(iso)
await db.flush()
db.add(
AttachmentPoint(
augmented_part_id=ids["s1"].id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
)
await db.commit()
guide = await scope_guide.build(db)
rows = {r["scope"].description: r for r in guide["rows"]}
s1_ap = rows["Alpha 3-18x50"]["objective_attachment_point"]
assert s1_ap is not None
assert s1_ap.iso_thread.diameter_mm == 44
# No "Objective"-named attachment point for s2 -> falls back to None.
assert rows["Zeta 1-6x24"]["objective_attachment_point"] is None
@pytest.mark.asyncio
async def test_scopes_page_renders_objective_attachment_point_thread(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
apn = AttachmentPointName(name="Objective")
db.add(apn)
await db.flush()
iso = IsoThread(diameter_mm=44, pitch_mm=0.5)
db.add(iso)
await db.flush()
db.add(
AttachmentPoint(
augmented_part_id=ids["s1"].id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
)
await db.commit()
await make_user("reader5@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader5@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
body = resp.text
# The attachment point's thread spec wins over ScopeInfo.objective_thread_pitch.
assert "M44.0×0.5" in body
assert "M52x0.75" not in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_scopes_page_renders_objective_lens_diameter_in_magnification( async def test_scopes_page_renders_objective_lens_diameter_in_magnification(
client, seeded, make_user, db_sessionmaker client, seeded, make_user, db_sessionmaker