Compare commits

...
9 Commits
26 changed files with 836 additions and 203 deletions

No files matched your search

+6 -1
View File
@@ -34,7 +34,12 @@
"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)",
"Bash(rg -n \"hx-delete|router.delete\" app/web/admin.py app/templates/admin/*.html)",
"Bash(rg -n \"is_superuser\" tests/conftest.py tests/*.py)"
] ]
} }
} }
+3
View File
@@ -25,6 +25,9 @@ AUTO_CREATE_TABLES=true
COOKIE_NAME=access_token COOKIE_NAME=access_token
# Set true when serving over HTTPS. # Set true when serving over HTTPS.
COOKIE_SECURE=false COOKIE_SECURE=false
# How long a browser login lasts before being bounced back to /login.
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
SESSION_TIMEOUT_HOURS=24
# --- LDAP (optional; direct-bind) --- # --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory # When enabled, the login page and /api/auth/ldap-login accept a directory
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION := $(shell git describe --tags --always) VERSION := $(shell git tag | tail -1)
IMAGE_NAME := fitguide IMAGE_NAME := fitguide
sync: sync:
+2 -1
View File
@@ -149,8 +149,9 @@ Every setting is read from an environment variable first, then `.env`
|----------|---------|-------| |----------|---------|-------|
| `SECRET_KEY` | dev placeholder | **set a strong value in production** | | `SECRET_KEY` | dev placeholder | **set a strong value in production** |
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres | | `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | | | `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
@@ -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 ###
+5 -1
View File
@@ -33,9 +33,13 @@ class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./app.db" database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie) # Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
# session_timeout_hours governs how long a browser login lasts before the
# user is bounced back to /login; it's independent of the JSON API's
# short-lived access_token_expire_minutes.
cookie_name: str = "access_token" cookie_name: str = "access_token"
cookie_secure: bool = False cookie_secure: bool = False
session_timeout_hours: int = 24
# LDAP (direct-bind). When enabled, the login page offers an LDAP option. # LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with # At login the user's DN is built from ``ldap_bind_dn_template`` (with
+4 -2
View File
@@ -47,10 +47,12 @@ def _create_token(
return token, payload return token, payload
def create_access_token(subject: str | int) -> str: def create_access_token(
subject: str | int, expires_delta: timedelta | None = None
) -> str:
token, _ = _create_token( token, _ = _create_token(
subject, subject,
timedelta(minutes=settings.access_token_expire_minutes), expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TYPE,
) )
return token return token
+11 -2
View File
@@ -1,4 +1,4 @@
from sqlalchemy import distinct, select from sqlalchemy import distinct, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts from app.models.associations import augmented_part_imported_parts
@@ -89,10 +89,19 @@ async def list_filtered(
direction: str = "asc", direction: str = "asc",
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
match_sku: bool = False,
) -> list[AugmentedPart]: ) -> list[AugmentedPart]:
q = select(AugmentedPart) q = select(AugmentedPart)
if description.strip(): if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%")) term = f"%{description.strip()}%"
if match_sku:
q = q.where(
or_(
AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term)
)
)
else:
q = q.where(AugmentedPart.description.ilike(term))
if colour_id is not None: if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id) q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None: if manufacturer_id is not None:
+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
)
+12 -3
View File
@@ -1,5 +1,7 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI.""" """Token issuance / rotation shared by the JSON API and the HTMX UI."""
from datetime import timedelta
import jwt import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +15,16 @@ from app.crud import token as token_crud
from app.models.user import User from app.models.user import User
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: async def issue_token_pair(
"""Create an access + refresh token and persist the refresh token for revocation.""" db: AsyncSession, user: User, access_expires_delta: timedelta | None = None
access = create_access_token(user.id) ) -> tuple[str, str]:
"""Create an access + refresh token and persist the refresh token for revocation.
``access_expires_delta`` overrides the access token's default lifetime
(``settings.access_token_expire_minutes``) — used by the web login flow to
issue a longer-lived session cookie without affecting API bearer tokens.
"""
access = create_access_token(user.id, access_expires_delta)
refresh, payload = create_refresh_token(user.id) refresh, payload = create_refresh_token(user.id)
await token_crud.store( await token_crud.store(
db, db,
-3
View File
@@ -16,12 +16,9 @@ from app.models.fit import Fit, FitNote
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
from app.models.iso_thread import IsoThread from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer 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_info import ScopeInfo
from app.models.scope_model import ScopeModel from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.unc_thread import UncThread from app.models.unc_thread import UncThread
# Children before the RESTRICT-protected reference tables they point to. # Children before the RESTRICT-protected reference tables they point to.
+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()
+7 -3
View File
@@ -33,9 +33,12 @@ async def build(
"""Return the context for ``scopes.html``. """Return the context for ``scopes.html``.
The scope rows are narrowed by the scope-attribute filters (``description`` The scope rows are narrowed by the scope-attribute filters (``description``
matches the scope's description; the ``*_id`` filters match exactly). matches either the scope's description or its SKU/product ID; the ``*_id``
``fits_part_type_id`` keeps only scopes that have at least one (non-``none``) filters match exactly). ``fits_part_type_id`` keeps only scopes that have
fit for that part type. at least one (non-``none``)
fit for that part type. Callers that paginate should slice ``rows``
themselves — this always returns the full filtered set, since
``fits_part_type_id`` is a post-filter that can't be pushed into the DB query.
Shape:: Shape::
@@ -69,6 +72,7 @@ async def build(
sub_part_type_id=sub_part_type_id, sub_part_type_id=sub_part_type_id,
part_type_id=scope_type.id, part_type_id=scope_type.id,
limit=1000, limit=1000,
match_sku=True,
) )
scope_ids = {s.id for s in scopes} scope_ids = {s.id for s in scopes}
if not scope_ids: if not scope_ids:
+5
View File
@@ -567,6 +567,11 @@ textarea:focus {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
/* Bulk actions (e.g. delete-selected above a checkbox table) */
.bulk-actions {
margin-bottom: 0.75rem;
}
.filter-bar form { .filter-bar form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+9 -2
View File
@@ -18,9 +18,13 @@
{%- endif -%} {%- endif -%}
{%- endmacro -%} {%- endmacro -%}
<div id="scopes-table-card" class="card" style="overflow-x:auto"> <div id="scopes-table-card" class="card" style="overflow-x:auto">
{% include "admin/_pagination.html" %}
<table class="table sortable"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
{% if can_write_scopes %}
<th data-sort-off><input type="checkbox" id="scopes-select-all" title="Select all"></th>
{% endif %}
<th>Scope</th> <th>Scope</th>
<th>Manufacturer</th> <th>Manufacturer</th>
<th>Model</th> <th>Model</th>
@@ -36,7 +40,10 @@
{% for row in rows %} {% for row in rows %}
{% set scope = row.scope %} {% set scope = row.scope %}
{% set info = row.info %} {% set info = row.info %}
<tr> <tr id="scope-row-{{ scope.id }}">
{% if can_write_scopes %}
<td><input type="checkbox" name="scope_ids" value="{{ scope.id }}"></td>
{% endif %}
<td> <td>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong> <strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %} {% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
@@ -70,7 +77,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="{{ 6 + column_types | length }}" class="muted"> <td colspan="{{ 6 + column_types | length + (1 if can_write_scopes else 0) }}" class="muted">
No scopes match your search. No scopes match your search.
</td> </td>
</tr> </tr>
+6 -6
View File
@@ -8,15 +8,15 @@
<div class="card" style="border: 1px solid var(--color-danger, #c00)"> <div class="card" style="border: 1px solid var(--color-danger, #c00)">
<h2>Purge database</h2> <h2>Purge database</h2>
<p> <p>
Permanently deletes every part, taxonomy entry, attachment point, fit, Permanently deletes every part, attachment point, fit, and imported
and imported part in the database — manufacturers, part colours, part part in the database — manufacturers, scope models, ISO/UNC threads,
types, sub part types, scope models, ISO/UNC threads, clamp/static clamp/static connections, attachment point names, augmented parts,
connections, attachment point names, augmented parts, scope info, scope info, attachment points, fits, fit notes, and imported parts.
attachment points, fits, fit notes, and imported parts.
</p> </p>
<p> <p>
<strong>Not</strong> deleted: users, roles &amp; permissions, refresh <strong>Not</strong> deleted: users, roles &amp; permissions, refresh
tokens, the audit log, and the OData connection configuration. tokens, the audit log, the OData connection configuration, and part
taxonomy (part colours, part types, sub part types).
</p> </p>
<p class="muted">This cannot be undone. Take a database backup first if you might need this data again.</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" <button class="btn btn-danger"
+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" %}
+45 -1
View File
@@ -15,7 +15,7 @@
hx-target="#scopes-table-card" hx-target="#scopes-table-card"
hx-trigger="input delay:300ms, change" hx-trigger="input delay:300ms, change"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="text" name="description" placeholder="Search scopes…" <input type="text" name="description" placeholder="Search by description or product ID…"
value="{{ filters.description or '' }}"> value="{{ filters.description or '' }}">
<select name="manufacturer_id"> <select name="manufacturer_id">
<option value="">All manufacturers</option> <option value="">All manufacturers</option>
@@ -44,6 +44,50 @@
</form> </form>
</div> </div>
{% if can_write_scopes %}
<div class="bulk-actions">
<button id="delete-selected-scopes-btn" type="button" class="btn btn-danger" disabled
hx-post="/scopes/bulk-delete"
hx-include="[name='scope_ids']:checked, [name='description'], [name='manufacturer_id'], [name='scope_model_id'], [name='sub_part_type_id'], [name='fits_part_type_id']"
hx-target="#scopes-table-card"
hx-swap="outerHTML"
hx-confirm="Delete selected scopes?">Delete selected</button>
</div>
{% endif %}
{% include "_scopes_table_card.html" %} {% include "_scopes_table_card.html" %}
</main> </main>
{% if can_write_scopes %}
<script>
(function () {
function updateDeleteButton() {
var count = document.querySelectorAll('input[name="scope_ids"]:checked').length;
var btn = document.getElementById('delete-selected-scopes-btn');
if (!btn) return;
btn.textContent = count > 0 ? 'Delete selected (' + count + ')' : 'Delete selected';
btn.disabled = count === 0;
btn.setAttribute('hx-confirm', 'Delete ' + count + ' selected scope' + (count === 1 ? '' : 's') + '?');
}
document.addEventListener('change', function (e) {
if (!e.target) return;
if (e.target.id === 'scopes-select-all') {
document.querySelectorAll('input[name="scope_ids"]').forEach(function (cb) {
cb.checked = e.target.checked;
});
}
if (e.target.id === 'scopes-select-all' || e.target.name === 'scope_ids') {
updateDeleteButton();
}
});
// Re-run after every HTMX swap (filter results, post-delete) so the button
// state reflects the freshly rendered checkboxes, which start unchecked.
document.addEventListener('htmx:afterSwap', updateDeleteButton);
}());
</script>
{% endif %}
{% endblock %} {% endblock %}
+144 -107
View File
@@ -1,8 +1,5 @@
"""HTMX-driven admin dashboard for users, roles, and thread standards.""" """HTMX-driven admin dashboard for users, roles, and thread standards."""
import math
import urllib.parse
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@@ -17,9 +14,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal, get_db from app.core.database import AsyncSessionLocal, get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as apn_crud
from app.crud import audit as audit_crud from app.crud import audit as audit_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 augmented_part_note as augmented_part_note_crud from app.crud import augmented_part_note as augmented_part_note_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.crud import imported_part as imported_part_crud from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_crud from app.crud import iso_thread as iso_thread_crud
@@ -29,11 +31,6 @@ from app.crud import part_type as part_type_crud
from app.crud import role as role_crud from app.crud import role as role_crud
from app.crud import scope_info as scope_info_crud from app.crud import scope_info as scope_info_crud
from app.crud import scope_model as scope_model_crud from app.crud import scope_model as scope_model_crud
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as apn_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.crud import static_connection as static_connection_crud from app.crud import static_connection as static_connection_crud
from app.crud import sub_part_type as sub_part_type_crud from app.crud import sub_part_type as sub_part_type_crud
from app.crud import unc_thread as unc_thread_crud from app.crud import unc_thread as unc_thread_crud
@@ -48,6 +45,8 @@ 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
from app.services import scope_import as scope_import_service from app.services import scope_import as scope_import_service
from app.web.pagination import paginate as _paginate
from app.web.pagination import query_url as _query_url
from app.web.templates import templates from app.web.templates import templates
router = APIRouter(prefix="/admin", include_in_schema=False) router = APIRouter(prefix="/admin", include_in_schema=False)
@@ -55,42 +54,6 @@ router = APIRouter(prefix="/admin", include_in_schema=False)
_PAGE_SIZE = 50 _PAGE_SIZE = 50
def _query_url(base_path: str, params: dict) -> str:
clean = {k: v for k, v in params.items() if v not in (None, "", 0)}
return f"{base_path}?{urllib.parse.urlencode(clean)}"
def _paginate(
base_path: str,
filter_params: dict,
page: int,
page_size: int,
total: int,
target_id: str,
) -> dict:
total_pages = max(1, math.ceil(total / page_size))
page = max(1, min(page, total_pages))
skip = (page - 1) * page_size
def _url(p: int) -> str:
return _query_url(
base_path, {**filter_params, "page": p, "page_size": page_size}
)
return {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"skip": skip,
"showing_from": skip + 1 if total > 0 else 0,
"showing_to": min(skip + page_size, total),
"prev_url": _url(page - 1) if page > 1 else None,
"next_url": _url(page + 1) if page < total_pages else None,
"target_id": target_id,
}
def _sort_headers( def _sort_headers(
base_path: str, base_path: str,
filter_params: dict, filter_params: dict,
@@ -1366,6 +1329,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,
@@ -1373,46 +1349,95 @@ 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."
try:
records = await odata_service.fetch(
config.odata_url, config.username, config.password
)
created, updated = await imported_part_crud.upsert_bulk(db, records)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ImportedPart",
resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
await _imported_table_ctx(
sync_result=f"Sync complete: {created} created, {updated} updated."
), ),
) )
except Exception as exc:
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:
records = await odata_service.fetch(
config.odata_url, config.username, config.password
)
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(
db,
actor=actor_row,
action="update",
resource_type="ImportedPart",
resource_id=None,
description=(
f"OData sync: {created} created, {updated} updated "
f"({len(records)} total records)"
),
)
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,
),
)
@router.get("/imported-parts/filter", response_class=HTMLResponse) @router.get("/imported-parts/filter", response_class=HTMLResponse)
@@ -1511,13 +1536,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(
@@ -1532,10 +1556,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)
) )
@@ -1545,9 +1575,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,
@@ -1718,13 +1749,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(
@@ -1738,18 +1768,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.skipped} skipped (type not recognised)" f"{result.deleted} deleted"
if result.skipped + (
else "" f", {result.skipped} skipped (type not recognised)"
) if result.skipped
+ "." 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",
@@ -1768,7 +1801,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(
@@ -2483,7 +2516,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
@@ -2500,10 +2533,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",
@@ -2527,7 +2561,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,
@@ -2774,7 +2808,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)
@@ -2789,13 +2823,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,
f"Recompute complete: {result.created} created, " message=(
f"{result.removed} removed." f"Recompute complete: {result.created} created, "
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",
@@ -2813,9 +2849,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":
+9 -3
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,7 +38,7 @@ def _set_auth_cookie(response: Response, access_token: str) -> None:
httponly=True, httponly=True,
secure=settings.cookie_secure, secure=settings.cookie_secure,
samesite="lax", samesite="lax",
max_age=settings.access_token_expire_minutes * 60, max_age=settings.session_timeout_hours * 3600,
path="/", path="/",
) )
@@ -79,7 +81,9 @@ async def login_submit(
}, },
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access, _ = await issue_token_pair(db, user) access, _ = await issue_token_pair(
db, user, timedelta(hours=settings.session_timeout_hours)
)
response = RedirectResponse( response = RedirectResponse(
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
) )
@@ -110,7 +114,9 @@ async def login_ldap_submit(
}, },
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access, _ = await issue_token_pair(db, user) access, _ = await issue_token_pair(
db, user, timedelta(hours=settings.session_timeout_hours)
)
response = RedirectResponse( response = RedirectResponse(
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
) )
+40
View File
@@ -0,0 +1,40 @@
"""Shared pagination helpers for HTMX table cards (admin screens, scopes page)."""
import math
import urllib.parse
def query_url(base_path: str, params: dict) -> str:
clean = {k: v for k, v in params.items() if v not in (None, "", 0)}
return f"{base_path}?{urllib.parse.urlencode(clean)}"
def paginate(
base_path: str,
filter_params: dict,
page: int,
page_size: int,
total: int,
target_id: str,
) -> dict:
total_pages = max(1, math.ceil(total / page_size))
page = max(1, min(page, total_pages))
skip = (page - 1) * page_size
def _url(p: int) -> str:
return query_url(
base_path, {**filter_params, "page": p, "page_size": page_size}
)
return {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
"skip": skip,
"showing_from": skip + 1 if total > 0 else 0,
"showing_to": min(skip + page_size, total),
"prev_url": _url(page - 1) if page > 1 else None,
"next_url": _url(page + 1) if page < total_pages else None,
"target_id": target_id,
}
+102 -7
View File
@@ -5,11 +5,13 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX
search/filter over scope attributes and required accessory fits. search/filter over scope attributes and required accessory fits.
""" """
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud
from app.crud import manufacturer as manufacturer_crud from app.crud import manufacturer as manufacturer_crud
from app.crud import part_type as part_type_crud from app.crud import part_type as part_type_crud
from app.crud import scope_model as scope_model_crud from app.crud import scope_model as scope_model_crud
@@ -17,17 +19,30 @@ from app.crud import sub_part_type as sub_part_type_crud
from app.models.user import User from app.models.user import User
from app.services import scope_guide as scope_guide_service from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require from app.web.admin import web_require
from app.web.pagination import paginate
from app.web.templates import templates from app.web.templates import templates
router = APIRouter(include_in_schema=False) router = APIRouter(include_in_schema=False)
_PAGE_SIZE = 50
_TABLE_TARGET = "#scopes-table-card"
_FILTER_PATH = "/scopes/filter"
def _to_int(value: str) -> int | None: def _to_int(value: str) -> int | None:
return int(value) if value.strip() else None return int(value) if value.strip() else None
def _can_write_scopes(user: User) -> bool:
return user.is_superuser or "augmented_parts:write" in user.permissions
async def _build(db: AsyncSession, filters: dict) -> dict: async def _build(db: AsyncSession, filters: dict) -> dict:
"""Run the guide query for the given raw (string) filter values.""" """Run the guide query for the given raw (string) filter values.
Always returns the full matching row set — pagination is applied by the
caller by slicing ``rows`` (see ``_paginated_guide``).
"""
return await scope_guide_service.build( return await scope_guide_service.build(
db, db,
description=filters["description"], description=filters["description"],
@@ -38,6 +53,22 @@ async def _build(db: AsyncSession, filters: dict) -> dict:
) )
async def _paginated_guide(
db: AsyncSession, filters: dict, page: int, page_size: int
) -> tuple[dict, dict]:
"""Run the guide query, then slice its rows to the requested page.
Returns ``(guide, pagination)`` where ``guide["rows"]`` is already sliced.
"""
guide = await _build(db, filters)
all_rows = guide["rows"]
pagination = paginate(
_FILTER_PATH, filters, page, page_size, len(all_rows), _TABLE_TARGET
)
guide["rows"] = all_rows[pagination["skip"] : pagination["skip"] + page_size]
return guide, pagination
async def _filter_options(db: AsyncSession) -> dict: async def _filter_options(db: AsyncSession) -> dict:
"""Dropdown option lists for the filter bar.""" """Dropdown option lists for the filter bar."""
scope_type = await part_type_crud.get_by_name( scope_type = await part_type_crud.get_by_name(
@@ -73,11 +104,18 @@ async def scopes_page(
"sub_part_type_id": sub_part_type_id, "sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id, "fits_part_type_id": fits_part_type_id,
} }
guide = await _build(db, filters) guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"scopes.html", "scopes.html",
{"user": user, "filters": filters, **guide, **(await _filter_options(db))}, {
"user": user,
"filters": filters,
"can_write_scopes": _can_write_scopes(user),
"pagination": pagination,
**guide,
**(await _filter_options(db)),
},
) )
@@ -89,8 +127,10 @@ async def scopes_filter(
scope_model_id: str = "", scope_model_id: str = "",
sub_part_type_id: str = "", sub_part_type_id: str = "",
fits_part_type_id: str = "", fits_part_type_id: str = "",
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:read", "fits:read")), user: User = Depends(web_require("augmented_parts:read", "fits:read")),
): ):
filters = { filters = {
"description": description, "description": description,
@@ -99,5 +139,60 @@ async def scopes_filter(
"sub_part_type_id": sub_part_type_id, "sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id, "fits_part_type_id": fits_part_type_id,
} }
guide = await _build(db, filters) guide, pagination = await _paginated_guide(db, filters, page, page_size)
return templates.TemplateResponse(request, "_scopes_table_card.html", guide) return templates.TemplateResponse(
request,
"_scopes_table_card.html",
{
**guide,
"pagination": pagination,
"can_write_scopes": _can_write_scopes(user),
},
)
@router.post("/scopes/bulk-delete", response_class=HTMLResponse)
async def bulk_delete_scopes(
request: Request,
scope_ids: list[int] = Form([]),
description: str = Form(""),
manufacturer_id: str = Form(""),
scope_model_id: str = Form(""),
sub_part_type_id: str = Form(""),
fits_part_type_id: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
for augmented_part_id in scope_ids:
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
continue
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
)
filters = {
"description": description,
"manufacturer_id": manufacturer_id,
"scope_model_id": scope_model_id,
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
guide, pagination = await _paginated_guide(db, filters, 1, _PAGE_SIZE)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",
{
**guide,
"pagination": pagination,
"can_write_scopes": _can_write_scopes(actor),
},
)
+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
+5 -4
View File
@@ -132,20 +132,17 @@ async def test_purge_wipes_domain_data_but_keeps_users_and_odata_config(
FitNote, FitNote,
ImportedPart, ImportedPart,
ScopeModel, ScopeModel,
SubPartType,
PartType,
AttachmentPointName, AttachmentPointName,
IsoThread, IsoThread,
UncThread, UncThread,
ClampConnection, ClampConnection,
StaticConnection, StaticConnection,
PartColour,
Manufacturer, Manufacturer,
): ):
rows = (await db.execute(Model.__table__.select())).all() rows = (await db.execute(Model.__table__.select())).all()
assert rows == [], f"{Model.__name__} should be empty after purge" assert rows == [], f"{Model.__name__} should be empty after purge"
# Kept: users, RBAC, and the OData connection config. # Kept: users, RBAC, the OData connection config, and part taxonomy.
from app.crud import user as user_crud from app.crud import user as user_crud
assert await user_crud.get_by_email(db, "root@example.com") is not None assert await user_crud.get_by_email(db, "root@example.com") is not None
@@ -153,5 +150,9 @@ async def test_purge_wipes_domain_data_but_keeps_users_and_odata_config(
assert config is not None assert config is not None
assert config.odata_url == "https://example.test/odata" 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) entries = await audit_crud.list_recent(db)
assert any("Purged all domain data" in e.description for e in entries) assert any("Purged all domain data" in e.description for e in entries)
+180 -1
View File
@@ -147,6 +147,26 @@ async def test_build_filters_by_description(db_sessionmaker):
assert _descriptions(guide) == ["Alpha 3-18x50"] assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio
async def test_build_filters_by_sku(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
ids["s1"].sku = "ARM-12345"
await db.commit()
guide = await scope_guide.build(db, description="ARM-12345")
assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio
async def test_build_filters_by_partial_sku(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
ids["s1"].sku = "ARM-12345"
await db.commit()
guide = await scope_guide.build(db, description="12345")
assert _descriptions(guide) == ["Alpha 3-18x50"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_filters_by_manufacturer(db_sessionmaker): async def test_build_filters_by_manufacturer(db_sessionmaker):
async with db_sessionmaker() as db: async with db_sessionmaker() as db:
@@ -275,7 +295,7 @@ async def test_scopes_page_renders_objective_attachment_point_thread(
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.text body = resp.text
# The attachment point's thread spec wins over ScopeInfo.objective_thread_pitch. # The attachment point's thread spec wins over ScopeInfo.objective_thread_pitch.
assert "M44×0.5" in body assert "M44.0×0.5" in body
assert "M52x0.75" not in body assert "M52x0.75" not in body
@@ -330,3 +350,162 @@ async def test_scopes_page_redirects_when_anonymous(client):
assert resp.status_code == 302 assert resp.status_code == 302
# Deep link is preserved so the user lands back here after logging in. # Deep link is preserved so the user lands back here after logging in.
assert resp.headers["location"] == "/login?next=%2Fscopes" assert resp.headers["location"] == "/login?next=%2Fscopes"
@pytest.mark.asyncio
async def test_scopes_page_hides_checkboxes_for_read_only_user(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
await make_user("reader6@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader6@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
assert 'name="scope_ids"' not in resp.text
assert "delete-selected-scopes-btn" not in resp.text
@pytest.mark.asyncio
async def test_scopes_page_shows_checkboxes_for_write_user(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
await make_user("writer@example.com", is_superuser=True)
await _web_login(client, "writer@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
assert f'name="scope_ids" value="{ids["s1"].id}"' in resp.text
assert "delete-selected-scopes-btn" in resp.text
assert 'hx-post="/scopes/bulk-delete"' in resp.text
@pytest.mark.asyncio
async def test_bulk_delete_scopes_removes_selected_augmented_parts(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
await make_user("writer2@example.com", is_superuser=True)
await _web_login(client, "writer2@example.com")
resp = await client.post(
"/scopes/bulk-delete",
data={"scope_ids": [str(ids["s1"].id), str(ids["s2"].id)]},
)
assert resp.status_code == 200
async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is None
assert await augmented_part_crud.get(db, ids["s2"].id) is None
@pytest.mark.asyncio
async def test_bulk_delete_scopes_leaves_unselected_rows(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
await make_user("writer3@example.com", is_superuser=True)
await _web_login(client, "writer3@example.com")
resp = await client.post(
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
)
assert resp.status_code == 200
async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is None
assert await augmented_part_crud.get(db, ids["s2"].id) is not None
@pytest.mark.asyncio
async def test_scopes_page_shows_single_page_summary(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_guide_data(db) # 2 scopes, well under the default page size
await make_user("pager1@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "pager1@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
assert "Showing 2 records." in resp.text
assert "Next" not in resp.text
@pytest.mark.asyncio
async def test_scopes_filter_paginates_results(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
scope_t = PartType(name="Scope")
db.add(scope_t)
await db.flush()
for i in range(5):
db.add(
AugmentedPart(
description=f"Scope {i:02d}",
colour_id=1,
manufacturer_id=1,
part_type_id=scope_t.id,
)
)
await db.commit()
await make_user("pager2@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "pager2@example.com")
resp = await client.get("/scopes/filter", params={"page_size": 2})
assert resp.status_code == 200
body = resp.text
assert "Scope 00" in body
assert "Scope 01" in body
assert "Scope 02" not in body
assert "Showing 1–2 of 5." in body
resp2 = await client.get("/scopes/filter", params={"page_size": 2, "page": 3})
assert resp2.status_code == 200
body2 = resp2.text
assert "Scope 04" in body2
assert "Scope 00" not in body2
assert "Showing 5–5 of 5." in body2
@pytest.mark.asyncio
async def test_scopes_filter_clamps_out_of_range_page(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_guide_data(db) # 2 scopes
await make_user("pager3@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "pager3@example.com")
resp = await client.get("/scopes/filter", params={"page_size": 1, "page": 99})
assert resp.status_code == 200
# Clamped to the last real page (2), not an empty page 99.
assert "Zeta 1-6x24" in resp.text
assert "Alpha 3-18x50" not in resp.text
@pytest.mark.asyncio
async def test_bulk_delete_scopes_forbidden_without_write_permission(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
await make_user("reader7@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader7@example.com")
resp = await client.post(
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
)
assert resp.status_code == 403
async with db_sessionmaker() as db:
from app.crud import augmented_part as augmented_part_crud
assert await augmented_part_crud.get(db, ids["s1"].id) is not None