Compare commits

..
6 Commits
Author SHA1 Message Date
Ian Roddis 7a15d9f7c0 Adding a bulk delete option to the scopes page 2026-08-10 12:15:09 -03:00
Ian Roddis a9c4c16d4f Extending session login time 2026-08-10 12:09:51 -03:00
Ian Roddis 14aa2ac5d6 Adding delete option for scopes 2026-08-10 12:04:56 -03:00
Ian Roddis f4771e33d4 Fixing tag detection 2026-08-05 15:27:17 -04:00
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
20 changed files with 612 additions and 117 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
+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,
+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()
+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;
+8 -2
View File
@@ -21,6 +21,9 @@
<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 +39,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 +76,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>
+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" %}
+44
View File
@@ -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 %}
+118 -44
View File
@@ -1366,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,
@@ -1373,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,
),
) )
@@ -1511,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(
@@ -1532,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)
) )
@@ -1545,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,
@@ -1718,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(
@@ -1738,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",
@@ -1768,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(
@@ -2483,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
@@ -2500,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",
@@ -2527,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,
@@ -2774,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)
@@ -2789,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",
@@ -2813,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":
+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
) )
+63 -4
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
@@ -26,6 +28,10 @@ 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."""
return await scope_guide_service.build( return await scope_guide_service.build(
@@ -77,7 +83,13 @@ async def scopes_page(
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),
**guide,
**(await _filter_options(db)),
},
) )
@@ -90,7 +102,7 @@ async def scopes_filter(
sub_part_type_id: str = "", sub_part_type_id: str = "",
fits_part_type_id: str = "", fits_part_type_id: str = "",
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,
@@ -100,4 +112,51 @@ async def scopes_filter(
"fits_part_type_id": fits_part_type_id, "fits_part_type_id": fits_part_type_id,
} }
guide = await _build(db, filters) guide = await _build(db, filters)
return templates.TemplateResponse(request, "_scopes_table_card.html", guide) return templates.TemplateResponse(
request,
"_scopes_table_card.html",
{**guide, "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 = await _build(db, filters)
return templates.TemplateResponse(
request,
"_scopes_table_card.html",
{**guide, "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
+92
View File
@@ -330,3 +330,95 @@ 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_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