Compare commits

..
38 Commits
Author SHA1 Message Date
Ian Roddis 16ba09f073 Fixing serialization for manufacturers in schema 2026-09-04 22:57:48 -03:00
Ian Roddis 53fe03fb2d Fixing serialization for manufacturers 2026-09-04 22:46:30 -03:00
Ian Roddis c414708b32 Fixing migrations for postgres 2026-08-18 21:28:39 -03:00
Ian Roddis 443a128ad7 Adding migrations to entrypoint 2026-08-18 21:12:20 -03:00
Ian Roddis 167ef3a409 Adding optic fits data 2026-08-18 16:57:56 -03:00
Ian Roddis b4dcb68bea Adding Manual connection point to allow undeclared fits 2026-08-18 16:57:28 -03:00
Ian Roddis 7e246be39c Adding autovivify aps 2026-08-18 16:33:23 -03:00
Ian Roddis 34d538f9a0 Adjusting default product name 2026-08-18 16:19:03 -03:00
Ian Roddis 9a9ebab7c8 Making dashboard a bit more useful 2026-08-18 14:07:18 -03:00
Ian Roddis 3253d8e858 Allowing unauthenticated users to see scopes 2026-08-18 13:44:55 -03:00
Ian Roddis 2ce4f91e4f Relayout of Augmented Part details page 2026-08-18 13:12:38 -03:00
Ian Roddis 6806caf8d7 Rearranging some information layout in augmented parts, adding colourization to scope table. 2026-08-18 13:01:37 -03:00
Ian Roddis 387ae49efc Updating TODO 2026-08-17 22:20:00 -03:00
Ian Roddis b7c4b05150 Apply ruff fixes 2026-08-17 22:19:28 -03:00
Ian Roddis 413e9e8381 Adding archived flag to augmented parts, fixing migrations so that creation from scratch is successful 2026-08-17 22:16:53 -03:00
Ian Roddis 740dfc97c7 Running black on all files 2026-08-17 21:33:48 -03:00
Ian Roddis 3237824e83 Fixing unit test issues 2026-08-17 21:30:52 -03:00
Ian Roddis 082ad16963 Fixing test case for attachment label matching 2026-08-17 21:18:26 -03:00
Ian Roddis 85cb58ef65 Adding support for clamp inference 2026-08-17 21:16:59 -03:00
Ian Roddis ab5315b862 Disabling attachment point label for purposes of finding fits 2026-08-17 21:08:37 -03:00
Ian Roddis 4d6fda6f74 Adding TODO 2026-08-17 16:56:19 -03:00
Ian Roddis a35d064e32 Renaming Occular to Ocular 2026-08-17 16:55:57 -03:00
Ian Roddis ce2bdc43a8 Making text search apply to both description and sku in imported parts and augmented parts 2026-08-17 16:05:01 -03:00
Ian Roddis 292830ae0f Removing objective_thread_pitch from scopes_info 2026-08-17 15:48:50 -03:00
Ian Roddis 067ddffbe5 Augmented Parts detail: description changed to sku for title 2026-08-17 15:09:52 -03:00
Ian Roddis bdb6c7ebfc Tightening up scope page and adding mouseover hints 2026-08-17 15:06:44 -03:00
Ian Roddis 2318a6daeb Making scope filter by product id 2026-08-11 13:11:15 -03:00
Ian Roddis 6d7c5c5604 Adding pagination to scopes page 2026-08-11 11:33:56 -03:00
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
Ian Roddis 2a0b8fcab2 Fixing errors and danger zone 2026-08-05 14:19:25 -03:00
Ian Roddis 392b79e801 Fixing presentation of objective thread 2026-08-05 14:15:10 -03:00
Ian Roddis 3ebe9a0894 Fixing imports 2026-08-05 13:59:57 -03:00
Ian Roddis 8d37b2ad67 Fixing column names, fixing model number 2026-08-05 13:35:09 -03:00
101 changed files with 3498 additions and 617 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(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 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
# Set true when serving over HTTPS.
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) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
+2 -2
View File
@@ -1,4 +1,4 @@
VERSION := $(shell git describe --tags --always)
VERSION := $(shell git tag | tail -1)
IMAGE_NAME := fitguide
sync:
@@ -8,7 +8,7 @@ seed:
uv run alembic upgrade head
uv run python -m scripts.seed
dev:
dev: migrate
uv run uvicorn app.main:app --reload
open http://localhost:8000
+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** |
| `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` | |
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
@@ -0,0 +1,45 @@
"""add jobs table
Revision ID: 04425ce3d4d5
Revises: a3f7c19b2d40
Create Date: 2026-08-05 15:21:09.527442
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "04425ce3d4d5"
down_revision: str | None = "a3f7c19b2d40"
branch_labels: str | Sequence[str] | None = None
depends_on: 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,25 +5,28 @@ Revises: 5b6a7817b408
Create Date: 2026-07-02 12:22:24.106795
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0c3d1a5cff1f"
down_revision: Union[str, None] = "5b6a7817b408"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "5b6a7817b408"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"part_colours",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("part_colours")
@@ -5,16 +5,17 @@ Revises: adf4a5b9493e
Create Date: 2026-07-14 15:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "1b99be8a9b1e"
down_revision: Union[str, None] = "adf4a5b9493e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "adf4a5b9493e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -0,0 +1,30 @@
"""drop_objective_thread_pitch_from_scope_infos
Revision ID: 235d823a56ee
Revises: 04425ce3d4d5
Create Date: 2026-08-17 15:18:26.763831
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "235d823a56ee"
down_revision: str | None = "04425ce3d4d5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("scope_infos", schema=None) as batch_op:
batch_op.drop_column("objective_thread_pitch")
def downgrade() -> None:
with op.batch_alter_table("scope_infos", schema=None) as batch_op:
batch_op.add_column(
sa.Column("objective_thread_pitch", sa.String(), nullable=True)
)
@@ -5,25 +5,29 @@ Revises: 7be5ef599d3f
Create Date: 2026-07-02 11:57:13.095417
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "5b6a7817b408"
down_revision: Union[str, None] = "7be5ef599d3f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "7be5ef599d3f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"static_connections",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("od_mm", sa.Float(), nullable=False),
sa.Column("od_in", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("od_mm"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("static_connections")
@@ -6,16 +6,15 @@ Create Date: 2026-06-24 21:50:05.340235
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "627d4460a94f"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,25 +5,28 @@ Revises: c7882cedc9ae
Create Date: 2026-07-02 13:54:58.335394
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "70e48bda8829"
down_revision: Union[str, None] = "c7882cedc9ae"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "c7882cedc9ae"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"attachment_point_names",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("attachment_point_names")
@@ -5,25 +5,42 @@ Revises: b60d0aa3ceda
Create Date: 2026-06-25 15:29:20.685050
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "7be5ef599d3f"
down_revision: Union[str, None] = "b60d0aa3ceda"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "b60d0aa3ceda"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"imported_parts",
sa.Column("InventoryID", sa.String(), nullable=False),
sa.Column("Description", sa.String(), nullable=True),
sa.Column("TBXColour", sa.String(), nullable=True),
sa.Column("ProductMaxThreadDepthmm", sa.String(), nullable=True),
sa.Column("ProductMaxThreadDepthin", sa.String(), nullable=True),
sa.Column("ProductODmm", sa.String(), nullable=True),
sa.Column("ProductODin", sa.String(), nullable=True),
sa.Column("TBXThreadAttribute", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("InventoryID"),
)
op.create_table(
"imported_part_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("odata_url", sa.String(), nullable=True),
sa.Column("username", sa.String(), nullable=True),
sa.Column("password", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("imported_part_config")
op.drop_table("imported_parts")
@@ -5,16 +5,17 @@ Revises: d1e2f3a4b5c6
Create Date: 2026-07-31 09:07:06.137175
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8012c00b0c04"
down_revision: Union[str, None] = "d1e2f3a4b5c6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "d1e2f3a4b5c6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,25 +5,48 @@ Revises: 0c3d1a5cff1f
Create Date: 2026-07-02 12:37:59.457033
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8bc411b5814e"
down_revision: Union[str, None] = "0c3d1a5cff1f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "0c3d1a5cff1f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"augmented_parts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("sku", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=False),
sa.Column("colour_id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("scope_model_id", sa.Integer(), nullable=True),
sa.Column("part_type_id", sa.Integer(), nullable=False),
sa.Column("sub_part_type_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["colour_id"], ["part_colours.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["scope_model_id"], ["scope_models.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["sub_part_type_id"], ["sub_part_types.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("augmented_parts")
@@ -0,0 +1,32 @@
"""add_archived_to_augmented_parts
Revision ID: 9a1c2f6e0b3d
Revises: 235d823a56ee
Create Date: 2026-08-17 21:30:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9a1c2f6e0b3d"
down_revision: str | None = "235d823a56ee"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"archived", sa.Boolean(), nullable=False, server_default=sa.false()
)
)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_column("archived")
@@ -5,16 +5,17 @@ Revises: 627d4460a94f
Create Date: 2026-06-25 10:32:11.398529
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a192abbf4091"
down_revision: Union[str, None] = "627d4460a94f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "627d4460a94f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -27,12 +28,19 @@ def upgrade() -> None:
sa.Column("logo_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# batch_alter_table's alter_column doesn't emit CREATE TYPE for the new
# Enum on its own (unlike create_table, which ties enum creation to the
# table-create DDL event) - create it explicitly first, or Postgres fails
# with `type "uncseries" does not exist`. checkfirst / no-op on SQLite.
uncseries = sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries")
uncseries.create(op.get_bind(), checkfirst=True)
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column(
"series",
existing_type=sa.VARCHAR(length=8),
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
type_=uncseries,
existing_nullable=False,
postgresql_using="series::uncseries",
)
# ### end Alembic commands ###
@@ -46,7 +54,9 @@ def downgrade() -> None:
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
type_=sa.VARCHAR(length=8),
existing_nullable=False,
postgresql_using="series::varchar(8)",
)
sa.Enum(name="uncseries").drop(op.get_bind(), checkfirst=True)
op.drop_table("manufacturers")
# ### end Alembic commands ###
@@ -5,16 +5,17 @@ Revises: 8012c00b0c04
Create Date: 2026-08-04 00:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a3f7c19b2d40"
down_revision: Union[str, None] = "8012c00b0c04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "8012c00b0c04"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,16 +5,17 @@ Revises: a192abbf4091
Create Date: 2026-06-25 10:35:40.227253
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a52e7ca7a925"
down_revision: Union[str, None] = "a192abbf4091"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "a192abbf4091"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,25 +5,52 @@ Revises: 70e48bda8829
Create Date: 2026-07-02 14:03:23.561253
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "adf4a5b9493e"
down_revision: Union[str, None] = "70e48bda8829"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "70e48bda8829"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"attachment_points",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("attachment_point_name_id", sa.Integer(), nullable=False),
sa.Column("iso_thread_id", sa.Integer(), nullable=True),
sa.Column("unc_thread_id", sa.Integer(), nullable=True),
sa.Column("clamp_connection_id", sa.Integer(), nullable=True),
sa.Column("static_connection_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["attachment_point_name_id"],
["attachment_point_names.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["iso_thread_id"], ["iso_threads.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["unc_thread_id"], ["unc_threads.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["clamp_connection_id"], ["clamp_connections.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["static_connection_id"], ["static_connections.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("attachment_points")
@@ -5,25 +5,32 @@ Revises: fd5379986da4
Create Date: 2026-06-25 11:29:20.420056
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b60d0aa3ceda"
down_revision: Union[str, None] = "fd5379986da4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "fd5379986da4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"sub_part_types",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("part_type_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", "part_type_id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("sub_part_types")
@@ -5,25 +5,33 @@ Revises: 8bc411b5814e
Create Date: 2026-07-02 12:46:27.493942
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "bfbce324cd5f"
down_revision: Union[str, None] = "8bc411b5814e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "8bc411b5814e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"augmented_part_imported_parts",
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("imported_part_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["imported_part_id"], ["imported_parts.InventoryID"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("augmented_part_id", "imported_part_id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("augmented_part_imported_parts")
@@ -5,25 +5,30 @@ Revises: bfbce324cd5f
Create Date: 2026-07-02 13:31:57.064381
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c7882cedc9ae"
down_revision: Union[str, None] = "bfbce324cd5f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "bfbce324cd5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"clamp_connections",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("min_mm", sa.Float(), nullable=True),
sa.Column("max_mm", sa.Float(), nullable=True),
sa.Column("min_in", sa.Float(), nullable=True),
sa.Column("max_in", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table("clamp_connections")
@@ -5,16 +5,17 @@ Revises: c9f1a2b3d4e5
Create Date: 2026-07-16 20:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "d1e2f3a4b5c6"
down_revision: Union[str, None] = "c9f1a2b3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "c9f1a2b3d4e5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,16 +5,17 @@ Revises: a52e7ca7a925
Create Date: 2026-06-25 10:49:56.463466
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e23aa8dd2536"
down_revision: Union[str, None] = "a52e7ca7a925"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "a52e7ca7a925"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -5,25 +5,43 @@ Revises: e23aa8dd2536
Create Date: 2026-06-25 11:06:13.028067
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "fd5379986da4"
down_revision: Union[str, None] = "e23aa8dd2536"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "e23aa8dd2536"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("user_email", sa.String(length=255), nullable=False),
sa.Column("action", sa.String(length=16), nullable=False),
sa.Column("resource_type", sa.String(length=64), nullable=False),
sa.Column("resource_id", sa.Integer(), nullable=True),
sa.Column("description", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_table("audit_logs")
+7 -3
View File
@@ -27,7 +27,7 @@ def _connection_label(ap) -> str:
return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD"
return "—"
return "Manual"
@router.get("", response_model=list[AttachmentPointRead])
@@ -54,10 +54,14 @@ async def create_attachment_point(
payload.clamp_connection_id,
payload.static_connection_id,
]
if sum(v is not None for v in conn_ids) != 1:
if sum(v is not None for v in conn_ids) > 1:
raise HTTPException(
status_code=400,
detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
detail=(
"At most one connection (iso_thread, unc_thread, clamp_connection, "
"or static_connection) may be provided. Leave all unset for a "
"manual attachment point."
),
)
ap = await attachment_point_crud.create(db, **payload.model_dump())
await audit_crud.log(
+5 -2
View File
@@ -24,10 +24,13 @@ router = APIRouter()
async def list_augmented_parts(
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
return await augmented_part_crud.list_all(db, skip=skip, limit=limit)
return await augmented_part_crud.list_all(
db, skip=skip, limit=limit, include_archived=include_archived
)
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
@@ -72,7 +75,7 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.deleted} deleted, "
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
+16 -1
View File
@@ -13,6 +13,7 @@ from app.schemas.imported_part import (
ImportedPartRead,
SyncResult,
)
from app.services import augmented_part_sync
from app.services import odata as odata_service
router = APIRouter()
@@ -128,7 +129,9 @@ async def delete_imported_part(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
await imported_part_crud.delete(db, part)
archived = await augmented_part_sync.delete_imported_part_and_archive_orphans(
db, part
)
await audit_crud.log(
db,
actor=actor,
@@ -137,3 +140,15 @@ async def delete_imported_part(
resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
)
if archived:
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Archived {archived} augmented part(s) left with no remaining "
f"imported parts after deleting {inventory_id!r}"
),
)
+5 -1
View File
@@ -33,9 +33,13 @@ class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./app.db"
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_secure: bool = False
session_timeout_hours: int = 24
# 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
+4 -2
View File
@@ -47,10 +47,12 @@ def _create_token(
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(
subject,
timedelta(minutes=settings.access_token_expire_minutes),
expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE,
)
return token
+32 -10
View File
@@ -1,4 +1,4 @@
from sqlalchemy import distinct, select
from sqlalchemy import distinct, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
@@ -58,14 +58,15 @@ async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
db: AsyncSession,
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
) -> list[AugmentedPart]:
result = await db.execute(
select(AugmentedPart)
.order_by(AugmentedPart.description)
.offset(skip)
.limit(limit)
)
q = select(AugmentedPart).order_by(AugmentedPart.description)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -85,6 +86,7 @@ async def list_filtered(
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
sort: str = "description",
direction: str = "asc",
skip: int = 0,
@@ -92,7 +94,10 @@ async def list_filtered(
) -> list[AugmentedPart]:
q = select(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
term = f"%{description.strip()}%"
q = q.where(
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
)
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
@@ -103,6 +108,8 @@ async def list_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -116,12 +123,16 @@ async def count_filtered(
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
) -> int:
from sqlalchemy import func
q = select(func.count()).select_from(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
term = f"%{description.strip()}%"
q = q.where(
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
)
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
@@ -132,6 +143,8 @@ async def count_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q)
return result.scalar_one()
@@ -245,6 +258,15 @@ async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
return list(result.scalars().all())
async def set_archived(
db: AsyncSession, ap: AugmentedPart, archived: bool
) -> AugmentedPart:
ap.archived = archived
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
await db.delete(ap)
await db.commit()
+15 -8
View File
@@ -1,4 +1,4 @@
from sqlalchemy import distinct, insert, select, update
from sqlalchemy import distinct, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part import ImportedPart
@@ -61,7 +61,13 @@ async def list_filtered(
) -> list[ImportedPart]:
q = select(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
term = f"%{description.strip()}%"
q = q.where(
or_(
ImportedPart.Description.ilike(term),
ImportedPart.InventoryID.ilike(term),
)
)
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -88,16 +94,17 @@ async def count_filtered(db: AsyncSession, description: str = "") -> int:
q = select(func.count()).select_from(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
term = f"%{description.strip()}%"
q = q.where(
or_(
ImportedPart.Description.ilike(term),
ImportedPart.InventoryID.ilike(term),
)
)
result = await db.execute(q)
return result.scalar_one()
async def delete(db: AsyncSession, part: ImportedPart) -> None:
await db.delete(part)
await db.commit()
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records:
-2
View File
@@ -37,7 +37,6 @@ async def create(
magnification_max: float | None = None,
model_suffix: str | None = None,
objective_lens_diameter_mm: float | None = None,
objective_thread_pitch: str | None = None,
) -> ScopeInfo:
info = ScopeInfo(
augmented_part_id=augmented_part_id,
@@ -45,7 +44,6 @@ async def create(
magnification_max=magnification_max,
model_suffix=model_suffix,
objective_lens_diameter_mm=objective_lens_diameter_mm,
objective_thread_pitch=objective_thread_pitch,
)
db.add(info)
await db.commit()
+1 -1
View File
@@ -49,7 +49,7 @@ def create_app() -> FastAPI:
# Preserve the originally requested URL so we can return there after
# login. Only meaningful for GET navigations (the ones users deep-link).
login_url = "/login"
if request.method == "GET" and request.url.path not in ("/", "/login"):
if request.method == "GET" and request.url.path != "/login":
target = request.url.path
if request.url.query:
target += "?" + request.url.query
+4 -2
View File
@@ -14,16 +14,17 @@ from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.job import Job
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.permission import Permission
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.unc_thread import UncThread
from app.models.user import User
@@ -40,6 +41,7 @@ __all__ = [
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Job",
"Manufacturer",
"PartColour",
"PartType",
+4 -1
View File
@@ -21,7 +21,10 @@ class AttachmentPoint(Base):
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
)
# Exactly one of the four below must be non-null (enforced in application layer)
# At most one of the four below is non-null (enforced in application layer).
# All four null means a "Manual" attachment point: no connection details,
# used to let a user assert a Fit by hand without the fit-matching algorithm
# (app.services.fit_matching) ever auto-matching it.
iso_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
)
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -36,6 +36,10 @@ class AugmentedPart(Base):
nullable=True,
default=None,
)
# Set instead of deleting the row when the last backing ImportedPart is
# removed, so fit/attachment-point history is preserved. Hidden from list
# views by default.
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# linked imported parts — not eagerly loaded; used by sync service only
imported_parts: Mapped[list[ImportedPart]] = relationship(
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class FitLabel(str, enum.Enum):
class FitLabel(enum.StrEnum):
POSSIBLE = "possible"
EXACT = "exact"
NONE = "none"
+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
)
+1 -1
View File
@@ -8,5 +8,5 @@ class Manufacturer(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=True)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
-5
View File
@@ -32,8 +32,3 @@ class ScopeInfo(Base):
objective_lens_diameter_mm: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
# Kept as the raw string from the sheet (e.g. "0.75", "1/16") so no precision
# or notation is lost; the parsed thread lives on its own AttachmentPoint.
objective_thread_pitch: Mapped[str | None] = mapped_column(
String, nullable=True, default=None
)
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UncSeries(str, enum.Enum):
class UncSeries(enum.StrEnum):
UNC = "UNC"
UNF = "UNF"
UNEF = "UNEF"
+3 -1
View File
@@ -53,11 +53,12 @@ class AugmentedPartUpdate(BaseModel):
scope_model_id: int | None = None
part_type_id: int | None = None
sub_part_type_id: int | None = None
archived: bool | None = None
class AugmentedPartSyncResult(BaseModel):
created: int
deleted: int
archived: int
skipped: int
@@ -65,6 +66,7 @@ class AugmentedPartRead(AugmentedPartBase):
model_config = ConfigDict(from_attributes=True)
id: int
archived: bool
colour: PartColourRead
manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None
+1 -1
View File
@@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, HttpUrl
class ManufacturerBase(BaseModel):
name: str
url: HttpUrl
url: HttpUrl | None = None
logo_url: HttpUrl | None = None
-1
View File
@@ -6,7 +6,6 @@ class ScopeInfoBase(BaseModel):
magnification_max: float | None = None
model_suffix: str | None = None
objective_lens_diameter_mm: float | None = None
objective_thread_pitch: str | None = None
class ScopeInfoRead(ScopeInfoBase):
+123 -43
View File
@@ -2,11 +2,12 @@
Algorithm
---------
Phase 1 – prune stale entries
Phase 1 – archive stale entries
• Find association rows whose imported_part_id no longer exists.
• Delete those rows.
• Delete any AugmentedPart that ends up with zero remaining associations
(i.e. it was created purely from now-deleted ImportedParts).
• Archive (rather than delete) any AugmentedPart that ends up with zero
remaining associations (i.e. it was created purely from now-deleted
ImportedParts), preserving its fit/attachment-point history.
Phase 2 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
@@ -17,7 +18,7 @@ Phase 2 – create entries for new ImportedParts
• Type ← first PartType whose name is a substring of the description
(longest name wins; skipped if no match)
• SubType ← null
• Attachment point ← always named "Occular"; one of:
• Attachment point ← always named "Ocular"; one of:
- ISO/UNC thread parsed from TBXThreadAttribute (using the
matching ProductMaxThreadDepth mm/in if populated), or
- StaticConnection from ProductODmm/ProductODin (whichever
@@ -33,6 +34,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as attachment_point_name_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud
@@ -46,7 +48,7 @@ from app.models.part_type import PartType
from app.models.unc_thread import UncSeries
from app.schemas.augmented_part import AugmentedPartSyncResult
ATTACHMENT_POINT_NAME = "Occular"
ATTACHMENT_POINT_NAME = "Ocular"
MM_PER_INCH = 25.4
# e.g.
@@ -70,6 +72,11 @@ _UNC_THREAD_RE = re.compile(
re.IGNORECASE,
)
_CLAMP_RANGE_RE = re.compile(
r"\((?P<min_diameter>\d*\.?\d+)\s*-\s*(?P<max_diameter>\d*\.?\d+)\s*mm\)",
re.IGNORECASE,
)
def _parse_float(value: str | None) -> float | None:
if value is None:
@@ -101,6 +108,13 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
"series": series,
}
if m := _CLAMP_RANGE_RE.match(value):
# Source data often omits the series letters; UNS ("unified special") is the
# correct designation when the diameter/TPI pairing isn't a standard pitch.
return "clamp", {
"min_diametere": float(m.group("min_diameter")),
"max_diameter": float(m.group("max_diameter")),
}
return None
@@ -144,6 +158,7 @@ async def _add_attachment_point(
attachment_point_name_id=apn.id,
iso_thread_id=thread.id,
)
return
elif kind == "unc":
depth_in = _parse_float(ip.ProductMaxThreadDepthin)
thread = await unc_thread_crud.get_by_values(
@@ -157,9 +172,21 @@ async def _add_attachment_point(
attachment_point_name_id=apn.id,
unc_thread_id=thread.id,
)
else:
pass
return
elif kind == "clamp":
clamp = await clamp_connection_crud.get_by_values(db, **fields)
if clamp is None:
clamp = await clamp_connection_crud.create(db, **fields)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
clamp_connection_id=clamp.id,
)
return
else:
# Unable to determine
pass
od_mm = _parse_float(ip.ProductODmm)
od_in = _parse_float(ip.ProductODin)
@@ -242,6 +269,91 @@ def _infer_part_type(description: str, part_types: list[PartType]) -> PartType |
return None
async def _archive_orphans(db: AsyncSession, candidate_ap_ids: set[int]) -> int:
"""Archive any of `candidate_ap_ids` left with zero linked ImportedParts.
Idempotent: already-archived parts and parts that still have a link are
left untouched. Does not commit — the caller commits as part of its own
transaction.
"""
archived = 0
for ap_id in candidate_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None and not ap.archived:
ap.archived = True
archived += 1
return archived
async def _detach_and_archive_orphans(
db: AsyncSession, imported_part_ids: set[str]
) -> int:
"""Detach `imported_part_ids` from any AugmentedPart association and archive
AugmentedParts left with zero remaining ImportedPart links as a result.
Deletes the association rows directly rather than relying on the
ImportedPart row's ON DELETE CASCADE, since SQLite (this app's dev/test
database) doesn't enforce FK actions by default. Safe to call whether or
not the ImportedPart row(s) still exist. Does not commit — the caller
commits as part of its own transaction.
"""
if not imported_part_ids:
return 0
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
await db.flush()
return await _archive_orphans(db, candidate_ap_ids)
async def delete_imported_part_and_archive_orphans(
db: AsyncSession, imported_part: ImportedPart
) -> int:
"""Delete `imported_part` and archive any AugmentedPart left with no
remaining ImportedPart links as a result.
Captures affected AugmentedPart ids before deleting so this is correct
regardless of whether the DB enforces the association table's ON DELETE
CASCADE. Returns the number of AugmentedParts newly archived.
"""
inventory_id = imported_part.InventoryID
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.delete(imported_part)
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
await db.flush()
archived = await _archive_orphans(db, candidate_ap_ids)
await db.commit()
return archived
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
@@ -256,42 +368,10 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
current_ids: set[str] = {row[0] for row in result.all()}
orphaned_import_ids = all_linked_ids - current_ids
deleted = 0
archived = 0
if orphaned_import_ids:
# Which AugmentedParts are affected?
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
# Remove the stale association rows
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
await db.flush()
# Delete AugmentedParts that now have no remaining associations
for ap_id in orphaned_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None:
await db.delete(ap)
deleted += 1
archived = await _detach_and_archive_orphans(db, orphaned_import_ids)
await db.commit()
# ------------------------------------------------------------------ phase 2
@@ -308,7 +388,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped
created=created, archived=archived, skipped=skipped
)
result = await db.execute(
@@ -359,4 +439,4 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
await db.commit()
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
+12 -3
View File
@@ -1,5 +1,7 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
from datetime import timedelta
import jwt
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +15,16 @@ from app.crud import token as token_crud
from app.models.user import User
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
"""Create an access + refresh token and persist the refresh token for revocation."""
access = create_access_token(user.id)
async def issue_token_pair(
db: AsyncSession, user: User, access_expires_delta: timedelta | None = None
) -> 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)
await token_crud.store(
db,
+54
View File
@@ -0,0 +1,54 @@
"""Irreversible bulk-delete of all domain (parts/taxonomy/fit) data.
Leaves users, roles/permissions, refresh tokens, the audit log, and the
ImportedPartConfig (OData connection settings) untouched.
"""
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitNote
from app.models.imported_part import ImportedPart
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.unc_thread import UncThread
# Children before the RESTRICT-protected reference tables they point to.
_TARGETS = [
("Fit notes", FitNote),
("Fits", Fit),
("Augmented part notes", AugmentedPartNote),
("Scope info", ScopeInfo),
("Attachment points", AttachmentPoint),
("Augmented part / imported part links", augmented_part_imported_parts),
("Augmented parts", AugmentedPart),
("Imported parts", ImportedPart),
("Scope models", ScopeModel),
("Attachment point names", AttachmentPointName),
("ISO threads", IsoThread),
("UNC threads", UncThread),
("Clamp connections", ClampConnection),
("Static connections", StaticConnection),
("Manufacturers", Manufacturer),
]
async def purge_domain_data(db: AsyncSession) -> dict[str, int]:
"""Delete every row from every domain table, in FK-safe order.
Returns a label -> rows-deleted map for reporting/audit purposes.
"""
counts: dict[str, int] = {}
for label, target in _TARGETS:
result = await db.execute(delete(target))
counts[label] = result.rowcount or 0
await db.commit()
return counts
+9 -3
View File
@@ -76,9 +76,15 @@ def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
"""True if these two attachment points satisfy the fit-matching algorithm."""
if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
return False
"""True if these two attachment points satisfy the fit-matching algorithm.
A "Manual" attachment point (all four connections null) always returns
False here — it never participates in algorithmic matching, only in
fits a user asserts by hand.
"""
# Disabled for now, the attachment_point_names should not be considered for the purposes of matching
# if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
# return False
return (
_iso_match(ap_a, ap_b)
or _unc_match(ap_a, ap_b)
+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
OS process (the app's current deployment: one uvicorn worker, no multi-process
or multi-replica setup — see ``entrypoint.sh`` / ``docker-compose.yml``). If
the app is ever scaled to multiple workers/replicas, this needs to move to
shared storage (a DB table, Redis) so a poll request landing on a different
process can still see the job.
Jobs are persisted to the ``jobs`` table rather than kept in a module-level
dict, so that a status-poll request landing on a *different* worker
process/replica than the one running the job can still see its progress —
required once the app runs with more than one uvicorn worker or replica
(a module-level dict is process-local and would report "not found" for a job
started elsewhere, even while that job is still running and writing to the
shared database).
"""
from __future__ import annotations
@@ -13,62 +14,83 @@ from __future__ import annotations
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
_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
class Job:
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
async def get(db: AsyncSession, job_id: str) -> Job | None:
return await db.get(Job, job_id)
_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.
def get(job_id: str) -> Job | None:
return _jobs.get(job_id)
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.
``run`` is responsible for calling ``mark_done``/``mark_error`` to record
the outcome, including on 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()
job = Job(id=uuid.uuid4().hex, kind=kind)
_jobs[job.id] = job
async with AsyncSessionLocal() as db:
await _evict_stale(db)
job = Job(id=uuid.uuid4().hex, kind=kind, status="running", message="Working…")
db.add(job)
await db.commit()
async def _runner() -> None:
try:
await run(job)
await run(job.id)
except Exception as exc:
job.status = "error"
job.message = f"{kind} failed: {exc}"
finally:
job.finished_at = datetime.now(UTC)
await mark_error(job.id, f"{kind} failed: {exc}")
asyncio.create_task(_runner())
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()
+1 -2
View File
@@ -7,7 +7,6 @@ successful profile is provisioned locally on first sign-in.
"""
import logging
from dataclasses import dataclass
from fastapi.concurrency import run_in_threadpool
@@ -43,7 +42,7 @@ def _bind_and_fetch(username: str, password: str) -> LdapIdentity | None:
)
try:
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
except LDAPException as e:
except LDAPException:
logging.exception("Failed to log into LDAP for user %s", user_dn)
# Wrong credentials, unreachable server, or a refused bind.
return None
+26 -3
View File
@@ -7,6 +7,7 @@ the parts of that type that fit the row's scope.
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import augmented_part as augmented_part_crud
from app.crud import fit as fit_crud
from app.crud import part_type as part_type_crud
@@ -14,6 +15,7 @@ from app.crud import scope_info as scope_info_crud
from app.models.fit import FitLabel
SCOPE_TYPE_NAME = "Scope"
OBJECTIVE_ATTACHMENT_POINT_NAME = "Objective"
# "exact" outranks "possible" when the same part fits a scope via several fits.
_LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1}
@@ -27,13 +29,21 @@ async def build(
scope_model_id: int | None = None,
sub_part_type_id: int | None = None,
fits_part_type_id: int | None = None,
only_exact: bool = False,
) -> dict:
"""Return the context for ``scopes.html``.
The scope rows are narrowed by the scope-attribute filters (``description``
matches the scope's description; the ``*_id`` filters match exactly).
``fits_part_type_id`` keeps only scopes that have at least one (non-``none``)
fit for that part type.
matches either the scope's description or its SKU/product ID; the ``*_id``
filters match exactly). ``fits_part_type_id`` keeps only scopes that have
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.
``only_exact`` drops ``possible`` fits entirely, keeping only user-confirmed
``exact`` ones — used for anonymous visitors, who shouldn't see unconfirmed
fit candidates.
Shape::
@@ -43,6 +53,7 @@ async def build(
{
"scope": AugmentedPart,
"info": ScopeInfo | None,
"objective_attachment_point": AttachmentPoint | None,
"cells": {part_type_id: [{"part": AugmentedPart, "label": str}]},
},
...,
@@ -74,6 +85,8 @@ async def build(
fits = await fit_crud.list_for_parts(
db, list(scope_ids), exclude_label=FitLabel.NONE
)
if only_exact:
fits = [f for f in fits if f.label == FitLabel.EXACT]
# Resolve the "other" (non-scope) part on each fit in a single batched query.
other_ids: set[int] = set()
@@ -85,6 +98,13 @@ async def build(
p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids))
}
objective_aps = {
ap.augmented_part_id: ap
for ap in await attachment_point_crud.list_by_augmented_parts_and_name(
db, list(scope_ids), OBJECTIVE_ATTACHMENT_POINT_NAME
)
}
infos = {
info.augmented_part_id: info
for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids))
@@ -100,6 +120,8 @@ async def build(
other = other_parts.get(other_id)
if other is None or other.part_type_id == scope_type.id:
continue # scope-to-scope fits have no column
if other.archived:
continue # archived parts don't show up as compatible accessories
by_type = cell_map[scope_id].setdefault(other.part_type_id, {})
existing = by_type.get(other.id)
label = fit.label.value
@@ -120,6 +142,7 @@ async def build(
{
"scope": scope,
"info": infos.get(scope.id),
"objective_attachment_point": objective_aps.get(scope.id),
"cells": cells,
}
)
+119 -39
View File
@@ -15,7 +15,6 @@ sub_part_type → SubPartType (get-or-create under "Scope"
product_number → AugmentedPart.sku (also the upsert / fit-lookup key)
magnification_min / _max → ScopeInfo
objective_lens_diameter_mm → ScopeInfo (descriptive only, no attachment point)
objective_thread_pitch → ScopeInfo
ocular_outer_diameter_mm → StaticConnection + AttachmentPoint "Ocular"
objective_thread → ISO/UNC thread + AttachmentPoint "Objective"
ocular_fit → Fit: scope's Ocular AP ↔ referenced part's first AP
@@ -26,8 +25,15 @@ and colour defaults to an "Unknown" PartColour, since the sheet carries neither.
Fits are created with label POSSIBLE. The referenced part is looked up by
``product_number`` and linked to its first attachment point (chosen answer:
"sole/first AP"). Both directions are resolved in a second pass so a fit between
two scopes in the same spreadsheet resolves regardless of row order.
"sole/first AP"). If the referenced part has no attachment points at all, one
is created for it that mirrors the scope's attachment point's connection, so
the fit has something valid to attach to. If the scope itself has no Ocular/
Objective attachment point (no ocular_outer_diameter_mm/objective_thread, or
an unparseable one), a Manual attachment point — no connection details, never
picked up by the fit-matching algorithm — is created on the scope under the
appropriate label so the fit still has somewhere to attach. Both directions
are resolved in a second pass so a fit between two scopes in the same
spreadsheet resolves regardless of row order.
Bad rows (missing key, unknown fit target, unparseable thread) are skipped and
reported per-row; good rows are committed as they go.
@@ -75,7 +81,6 @@ EXPECTED_COLUMNS = [
"url",
"ocular_outer_diameter_mm",
"ocular_fit",
"objective_thread_pitch",
"objective_thread",
"objective_fit",
]
@@ -140,15 +145,11 @@ def parse_workbook(data: bytes) -> list[dict]:
wb.close()
async def _static_ap(
db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float
):
async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float):
"""Create an attachment point carrying a StaticConnection for ``od_mm``."""
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
if sc is None:
sc = await static_connection_crud.create(
db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH
)
sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH)
apn = await apn_crud.get_by_name(db, label)
if apn is None:
apn = await apn_crud.create(db, name=label)
@@ -160,9 +161,7 @@ async def _static_ap(
)
async def _thread_ap(
db: AsyncSession, *, augmented_part_id: int, label: str, spec: str
):
async def _thread_ap(db: AsyncSession, *, augmented_part_id: int, label: str, spec: str):
"""Create an attachment point for a parsed ISO/UNC thread, or None."""
parsed = _parse_thread_attribute(spec)
if parsed is None:
@@ -194,6 +193,54 @@ async def _thread_ap(
)
async def _mirrored_ap(db: AsyncSession, *, augmented_part_id: int, scope_ap):
"""Create an attachment point mirroring ``scope_ap``'s connection.
Used when the referenced part has no attachment point that already fits
the scope, so the explicit ocular_fit/objective_fit link has something
valid to attach to.
"""
return await attachment_point_crud.create(
db,
augmented_part_id=augmented_part_id,
attachment_point_name_id=scope_ap.attachment_point_name_id,
iso_thread_id=scope_ap.iso_thread_id,
unc_thread_id=scope_ap.unc_thread_id,
clamp_connection_id=scope_ap.clamp_connection_id,
static_connection_id=scope_ap.static_connection_id,
)
async def _ensure_manual_scope_ap(
db: AsyncSession,
*,
scope_ap,
augmented_part_id: int,
label: str,
messages: list[str],
):
"""Return ``scope_ap``, creating a Manual attachment point named ``label``
on the scope if it's None.
Used so an ocular_fit/objective_fit reference still has something to link
from even when the scope has no ocular_outer_diameter_mm/objective_thread
of its own.
"""
if scope_ap is not None:
return scope_ap
apn = await apn_crud.get_by_name(db, label)
if apn is None:
apn = await apn_crud.create(db, name=label)
messages.append(
f"scope has no {label.lower()} attachment point; created a manual one to record the fit"
)
return await attachment_point_crud.create(
db,
augmented_part_id=augmented_part_id,
attachment_point_name_id=apn.id,
)
async def _link_fit(
db: AsyncSession,
*,
@@ -203,13 +250,13 @@ async def _link_fit(
scope_part_id: int,
messages: list[str],
) -> None:
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP."""
if scope_ap is None:
messages.append(
f"{scope_role}_fit '{target_sku}' skipped: scope has no "
f"{scope_role} attachment point"
)
return
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP.
``scope_ap`` must already exist — callers ensure this via
``_ensure_manual_scope_ap`` before calling. If the referenced part has no
attachment points at all, one is created mirroring the scope's attachment
point's connection.
"""
target = await augmented_part_crud.get_by_sku(db, target_sku)
if target is None:
messages.append(f"{scope_role}_fit references unknown product '{target_sku}'")
@@ -218,18 +265,17 @@ async def _link_fit(
messages.append(f"{scope_role}_fit '{target_sku}' skipped: self-reference")
return
target_aps = await attachment_point_crud.list_for_augmented_part(db, target.id)
if not target_aps:
messages.append(
f"{scope_role}_fit '{target_sku}' skipped: referenced part has no "
"attachment points"
)
return
if target_aps:
target_ap = target_aps[0]
else:
target_ap = await _mirrored_ap(db, augmented_part_id=target.id, scope_ap=scope_ap)
messages.append(
f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
"created one matching the scope's attachment point"
)
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
if existing is None:
await fit_crud.create(
db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE
)
await fit_crud.create(db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE)
async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult:
@@ -251,7 +297,21 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
# +2: 1-based, and the header occupies row 1.
row_num = offset + 2
model_suffix = _text(row.get("model_suffix"))
default_product_number = f"{_text(row.get('model'))} {row.get('magnification_min')}-{row.get('magnification_max')}x{row.get('objective_lens')} {row.get('model_suffix')}".strip()
model = _text(row.get("model"))
magnification_min = _num(row.get("magnification_min"))
magnification_max = _num(row.get("magnification_max"))
objective_lens_diameter_mm = _num(row.get("objective_lens_diameter_mm")) or _num(
row.get("objective_lens")
)
# Construct the model number
default_product_number = model
if magnification_min:
default_product_number += f" {magnification_min}-{magnification_max}"
if objective_lens_diameter_mm:
default_product_number += f"x{objective_lens_diameter_mm}"
if model_suffix:
default_product_number += f" {model_suffix}"
product_number = _text(row.get("product_number")) or default_product_number
result = ScopeImportRowResult(
row=row_num, product_number=product_number, status="skipped", messages=[]
@@ -273,9 +333,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name)
if manufacturer is None:
manufacturer = await manufacturer_crud.create(
db, name=manufacturer_name, url=""
)
manufacturer = await manufacturer_crud.create(db, name=manufacturer_name, url="")
model_name = _text(row.get("model"))
url = _text(row.get("url"))
@@ -333,14 +391,11 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
magnification_max=_num(row.get("magnification_max")),
model_suffix=model_suffix,
objective_lens_diameter_mm=_num(row.get("objective_lens_diameter_mm")),
objective_thread_pitch=_text(row.get("objective_thread_pitch")),
)
# Rebuild attachment points from scratch so re-imports stay idempotent
# (cascades delete any fits that referenced the old points).
for old_ap in await attachment_point_crud.list_for_augmented_part(
db, part.id
):
for old_ap in await attachment_point_crud.list_for_augmented_part(db, part.id):
await attachment_point_crud.delete(db, old_ap)
ocular_ap = None
@@ -366,8 +421,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
)
if objective_ap is None:
result.messages.append(
f"could not parse objective_thread "
f"'{objective_thread_spec}'"
f"could not parse objective_thread '{objective_thread_spec}'"
)
staged.append((row, ocular_ap, objective_ap, part.id, result))
@@ -383,6 +437,19 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
try:
ocular_fit = _text(row.get("ocular_fit"))
if ocular_fit:
ocular_ap = await _ensure_manual_scope_ap(
db,
scope_ap=ocular_ap,
augmented_part_id=part_id,
label=OCULAR_LABEL,
messages=result.messages,
)
fits = ocular_fit
if "/" in fits:
fits = [x.strip() for x in fits.split("/")]
else:
fits = [fits]
for ocular_fit in fits:
await _link_fit(
db,
scope_ap=ocular_ap,
@@ -393,6 +460,19 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
)
objective_fit = _text(row.get("objective_fit"))
if objective_fit:
objective_ap = await _ensure_manual_scope_ap(
db,
scope_ap=objective_ap,
augmented_part_id=part_id,
label=OBJECTIVE_LABEL,
messages=result.messages,
)
fits = objective_fit
if "/" in fits:
fits = [x.strip() for x in fits.split("/")]
else:
fits = [fits]
for objective_fit in fits:
await _link_fit(
db,
scope_ap=objective_ap,
+113 -34
View File
@@ -486,43 +486,89 @@ textarea:focus {
margin-bottom: 0.5rem;
}
.ap-item-name { font-weight: 500; flex: 0 0 auto; }
.ap-item-type { color: var(--muted); flex: 0 0 auto; font-size: 0.8rem; }
.ap-item-conn { flex: 1; }
.ap-item .row-actions { margin-left: auto; }
.fit-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
.ap-item-name {
font-weight: 500;
flex: 0 0 auto;
}
.fit-item:last-of-type { border-bottom: none; }
.fit-item-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.3rem;
.ap-item-type {
color: var(--muted);
flex: 0 0 auto;
font-size: 0.8rem;
}
.fit-item-arrow { color: var(--muted); }
.ap-item-conn {
flex: 1;
}
.ap-item .row-actions {
margin-left: auto;
}
.fit-item-arrow {
color: var(--muted);
text-align: center;
}
.fit-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-weight: 600;
}
.fit-badge-possible { background: var(--surface-2); color: var(--muted); }
.fit-badge-exact { background: #16a34a26; color: #16a34a; }
.fit-badge-none { background: #dc262626; color: #dc2626; }
/* Fits table on the augmented part detail page */
.fit-table select,
.fit-table input[type="text"] {
min-width: 8rem;
}
.fit-notes { margin-top: 0.4rem; padding-left: 0.25rem; }
.fit-table .fit-add-row {
background: var(--surface-2);
}
.fit-table .fit-add-row td {
vertical-align: top;
}
.fit-notes-cell {
min-width: 14rem;
}
.fit-badge-possible {
background: var(--surface-2);
color: var(--muted);
}
.fit-badge-exact {
background: #16a34a26;
color: #16a34a;
}
.fit-badge-none {
background: #dc262626;
color: #dc2626;
}
.fit-badge-possible a {
background: var(--surface-2);
color: var(--muted);
}
.fit-badge-exact a {
background: #16a34a26;
color: #16a34a;
}
.fit-badge-none a {
background: #dc262626;
color: #dc2626;
}
.fit-notes {
margin-top: 0.4rem;
padding-left: 0.25rem;
}
/* Scope guide: stacked matching-part entries within a fit column cell. */
.fit-cell-item {
@@ -532,14 +578,33 @@ textarea:focus {
font-size: 0.85rem;
white-space: nowrap;
}
.fit-cell-item + .fit-cell-item { margin-top: 0.3rem; }
.fit-note { font-size: 0.85rem; margin-bottom: 0.3rem; }
.fit-note-meta { color: var(--muted); font-size: 0.75rem; }
.fit-note-body { margin: 0.1rem 0 0; }
.fit-cell-item+.fit-cell-item {
margin-top: 0.3rem;
}
.fit-note-form { margin-top: 0.3rem; }
.fit-note-form input[type="text"] { flex: 1; min-width: 12rem; }
.fit-note {
font-size: 0.85rem;
margin-bottom: 0.3rem;
}
.fit-note-meta {
color: var(--muted);
font-size: 0.75rem;
}
.fit-note-body {
margin: 0.1rem 0 0;
}
.fit-note-form {
margin-top: 0.3rem;
}
.fit-note-form input[type="text"] {
flex: 1;
min-width: 12rem;
}
/* Pagination */
.pagination {
@@ -567,6 +632,18 @@ textarea:focus {
margin-bottom: 0.75rem;
}
/* Bulk actions (e.g. delete-selected above a checkbox table) */
.bulk-actions {
margin-bottom: 0.75rem;
}
.dashboard-sync-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.filter-bar form {
display: flex;
flex-wrap: wrap;
@@ -665,7 +742,9 @@ textarea:focus {
gap: 0.4rem;
}
.local-login-panel summary::-webkit-details-marker { display: none; }
.local-login-panel summary::-webkit-details-marker {
display: none;
}
.local-login-panel summary::before {
content: '›';
+17
View File
@@ -0,0 +1,17 @@
{%- macro connection_spec(ap) -%}
{%- if ap.iso_thread -%}
M{{ ap.iso_thread.diameter_mm }}×{{ ap.iso_thread.pitch_mm }}
{%- elif ap.unc_thread -%}
{{ ap.unc_thread.diameter_fraction }}-{{ ap.unc_thread.tpi }} {{ ap.unc_thread.series.value }}
{%- elif ap.clamp_connection -%}
{%- if ap.clamp_connection.min_mm is not none -%}
{{ ap.clamp_connection.min_mm }}–{{ ap.clamp_connection.max_mm }} mm
{%- else -%}
{{ ap.clamp_connection.min_in }}–{{ ap.clamp_connection.max_in }} in
{%- endif -%}
{%- elif ap.static_connection -%}
{{ ap.static_connection.od_mm }} mm OD
{%- else -%}
—
{%- endif -%}
{%- endmacro -%}
+32
View File
@@ -0,0 +1,32 @@
<div id="dashboard-sync-card" class="card">
<h2>Data sync</h2>
<dl class="kv">
<dt>Scopes</dt>
<dd>{{ scope_count }}</dd>
<dt>Imported parts skipped</dt>
<dd>{% if can_sync_augmented_parts %}<a href="/admin/augmented-parts/skipped">{{ skipped_count }}</a>{% else %}{{ skipped_count }}{% endif %}</dd>
</dl>
{% if status_message %}<p class="muted">✅ {{ status_message }}</p>{% endif %}
{% if status_error %}<p class="muted">⚠️ {{ status_error }}</p>{% endif %}
{% if can_refresh_odata or can_sync_augmented_parts or can_recompute_fits %}
<div class="dashboard-sync-actions">
{% if can_refresh_odata %}
<form hx-post="/dashboard/refresh-odata" hx-target="#dashboard-sync-card" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Refresh from OData</button>
</form>
{% endif %}
{% if can_sync_augmented_parts %}
<form hx-post="/dashboard/sync-augmented-parts" hx-target="#dashboard-sync-card" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Sync augmented parts</button>
</form>
{% endif %}
{% if can_recompute_fits %}
<form hx-post="/dashboard/recompute-fits" hx-target="#dashboard-sync-card" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Recompute possible fits</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
+11 -3
View File
@@ -1,11 +1,12 @@
{% set su = user.is_superuser %}
{% set p = user.permissions %}
{% set su = user.is_superuser if user else False %}
{% set p = user.permissions if user else [] %}
{% set can_admin = su or "users:read" in p or "roles:read" in p or "audit_log:read" in p %}
{% set can_connections = su or "clamp_connections:read" in p or "iso_threads:read" in p or "static_connections:read" in p or "unc_threads:read" in p %}
{% set can_labels = su or "attachment_point_names:read" in p or "manufacturers:read" in p or "part_colours:read" in p or "part_types:read" in p or "scope_models:read" in p or "sub_part_types:read" in p %}
{% set can_database = su or "imported_parts:read" in p or "augmented_parts:read" in p or "attachment_points:read" in p %}
{% set can_parts = can_connections or can_labels or can_database %}
{% set can_scopes = su or ("augmented_parts:read" in p and "fits:read" in p) %}
{# /scopes is public, so anonymous visitors see the link too; logged-in users still need the read perms. #}
{% set can_scopes = (not user) or su or ("augmented_parts:read" in p and "fits:read" in p) %}
{# ── SVG icon sprite ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -145,6 +146,9 @@
{% if su or "audit_log:read" in p %}
<a href="/admin/audit-log">{{ icon("list-checks") }} Audit Log</a>
{% endif %}
{% if su %}
<a href="/admin/danger-zone">{{ icon("alert-circle") }} Danger Zone</a>
{% endif %}
</div>
</div>
{% endif %}
@@ -229,8 +233,12 @@
<a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a>
</nav>
{% if user %}
<form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button>
</form>
{% else %}
<a href="/login" class="btn btn-ghost">Sign in</a>
{% endif %}
</header>
+15 -10
View File
@@ -1,3 +1,4 @@
{% from "_connection_macros.html" import connection_spec %}
{%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%}
{%- macro magnification_range(info) -%}
{%- if info and info.magnification_min is not none and info.magnification_max is not none -%}
@@ -17,12 +18,14 @@
{%- endif -%}
{%- endmacro -%}
<div id="scopes-table-card" class="card" style="overflow-x:auto">
{% include "admin/_pagination.html" %}
<table class="table sortable">
<thead>
<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>Manufacturer</th>
<th>Model</th>
<th>Type</th>
<th>Magnification</th>
<th>Objective thread</th>
@@ -35,18 +38,19 @@
{% for row in rows %}
{% set scope = row.scope %}
{% 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>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
</td>
<td>{{ scope.manufacturer.name if scope.manufacturer else "—" }}</td>
<td>{{ scope.scope_model.name if scope.scope_model else "—" }}</td>
<td>{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}</td>
<td>{{ magnification(info) }}</td>
<td>
{% if info and info.objective_thread_pitch %}
{{ info.objective_thread_pitch }}
{% if row.objective_attachment_point %}
{{ connection_spec(row.objective_attachment_point) }}
{% else %}<span class="muted">—</span>{% endif %}
</td>
{% for ct in column_types %}
@@ -55,8 +59,9 @@
{% if entries %}
{% for e in entries %}
<div class="fit-cell-item">
<span class="fit-badge fit-badge-{{ e.label }}">{{ e.label | capitalize }}</span>
<a href="/admin/augmented-parts/{{ e.part.id }}">{{ e.part.description | truncate(48) }}</a>
<span class="fit-badge fit-badge-{{ e.label }}">
<a href="/admin/augmented-parts/{{ e.part.id }}" title="{{ e.part.description | truncate(48) }}">{{ e.part.sku }}</a>
</span>
</div>
{% endfor %}
{% else %}
@@ -67,7 +72,7 @@
</tr>
{% else %}
<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.
</td>
</tr>
@@ -48,4 +48,6 @@
{% endfor %}
</select>
</label>
{% elif connection_type == "manual" %}
<p>No connection details — fits for this attachment point must be asserted manually.</p>
{% endif %}
@@ -15,7 +15,7 @@
{% set conn_type = "Static Connection" %}
{% set conn_label = ap.static_connection.od_mm ~ " mm OD" %}
{% else %}
{% set conn_type = "—" %}
{% set conn_type = "Manual" %}
{% set conn_label = "—" %}
{% endif %}
<tr id="attachment-point-{{ ap.id }}">
@@ -2,7 +2,7 @@
{% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif ap.static_connection_id %}{% set current_type = "static_connection" %}
{% else %}{% set current_type = "" %}{% endif %}
{% else %}{% set current_type = "manual" %}{% endif %}
<tr id="attachment-point-{{ ap.id }}">
<td colspan="6">
<form hx-post="/admin/attachment-points/{{ ap.id }}"
@@ -41,6 +41,7 @@
<option value="unc_thread" {% if current_type == "unc_thread" %}selected{% endif %}>UNC Thread</option>
<option value="clamp_connection" {% if current_type == "clamp_connection" %}selected{% endif %}>Clamp Connection</option>
<option value="static_connection" {% if current_type == "static_connection" %}selected{% endif %}>Static Connection</option>
<option value="manual" {% if current_type == "manual" %}selected{% endif %}>Manual</option>
</select>
</label>
{# Render the current connection select pre-filled #}
@@ -15,7 +15,7 @@
{% set conn_type = "Static" %}
{% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %}
{% else %}
{% set conn_type = "—" %}
{% set conn_type = "Manual" %}
{% set conn_label = "—" %}
{% endif %}
<div id="augmented-part-ap-{{ apoint.id }}" class="ap-item">
@@ -2,7 +2,7 @@
{% elif apoint.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif apoint.static_connection_id %}{% set current_type = "static_connection" %}
{% else %}{% set current_type = "" %}{% endif %}
{% else %}{% set current_type = "manual" %}{% endif %}
{# _attachment_point_connection_select.html expects `ap` for pre-selection #}
{% set ap = apoint %}
{% set connection_type = current_type %}
@@ -29,6 +29,7 @@
<option value="unc_thread" {{ "selected" if current_type == "unc_thread" }}>UNC Thread</option>
<option value="clamp_connection" {{ "selected" if current_type == "clamp_connection" }}>Clamp Connection</option>
<option value="static_connection" {{ "selected" if current_type == "static_connection" }}>Static Connection</option>
<option value="manual" {{ "selected" if current_type == "manual" }}>Manual</option>
</select>
</label>
<div id="edit-ap-{{ apoint.id }}-connection-options">
@@ -31,6 +31,7 @@
<option value="unc_thread">UNC Thread</option>
<option value="clamp_connection">Clamp Connection</option>
<option value="static_connection">Static Connection</option>
<option value="manual">Manual</option>
</select>
</label>
<div id="ap-{{ augmented_part_id }}-add-connection-options"></div>
@@ -8,46 +8,22 @@
{%- elif ap.static_connection -%}
Static {{ ap.static_connection.od_mm }} mm OD
{%- else -%}
—
Manual
{%- endif -%}
{%- endmacro %}
<div id="fit-{{ fit.id }}" class="fit-item">
<div class="fit-item-main">
<span class="fit-badge fit-badge-{{ fit.label.value }}">{{ fit.label.value | capitalize }}</span>
<span class="fit-item-side"><strong>{{ own_ap.attachment_point_name.name }}</strong> ({{ conn_desc(own_ap) }})</span>
<span class="fit-item-arrow">↔</span>
<span class="fit-item-side">{{ other_part.description if other_part else "Unknown part" }} — <strong>{{ other_ap.attachment_point_name.name }}</strong> ({{ conn_desc(other_ap) }})</span>
</div>
{% if can_write %}
<div class="row-actions">
{% if fit.label.value != "exact" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "exact"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Exact</button>
{% endif %}
{% if fit.label.value != "possible" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "possible"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Possible</button>
{% endif %}
{% if fit.label.value != "none" %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "none"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Impossible</button>
{% endif %}
<button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}"
hx-target="#ap-{{ augmented_part_id }}-fits"
hx-swap="outerHTML"
hx-confirm="Delete this fit?">Delete</button>
</div>
{% endif %}
<div class="fit-notes">
<tr id="fit-{{ fit.id }}" class="fit-row">
<td><span class="fit-badge fit-badge-{{ fit.label.value }}">{{ fit.label.value | capitalize }}</span></td>
<td>
<strong>{{ own_ap.attachment_point_name.name }}</strong>
<div class="muted" style="font-size:0.8rem">{{ conn_desc(own_ap) }}</div>
</td>
<td class="fit-item-arrow">↔</td>
<td>
<a href="/admin/augmented-parts/{{ other_part.id }}" title="{{ other_part.description }}">{{ other_part.sku or other_part.description }}</a>
<div class="muted" style="font-size:0.8rem">{{ other_part.part_type.name }}</div>
</td>
<td class="fit-notes-cell">
{% for note in fit.notes %}
<div class="fit-note">
<span class="fit-note-meta">{{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
@@ -59,8 +35,37 @@
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML"
class="inline-edit fit-note-form">
<input type="text" name="body" placeholder="Add a note…" required>
<button type="submit" class="btn btn-ghost">Add note</button>
<button type="submit" class="btn btn-ghost btn-sm">Add note</button>
</form>
{% endif %}
</td>
{% if can_write %}
<td>
<div class="row-actions">
{% if fit.label.value != "exact" %}
<button class="btn btn-ghost btn-sm"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "exact"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Exact</button>
{% endif %}
{% if fit.label.value != "possible" %}
<button class="btn btn-ghost btn-sm"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "possible"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Possible</button>
{% endif %}
{% if fit.label.value != "none" %}
<button class="btn btn-ghost btn-sm"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label"
hx-vals='{"label": "none"}'
hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML">Mark Impossible</button>
{% endif %}
<button class="btn btn-danger btn-sm"
hx-delete="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}"
hx-target="#ap-{{ augmented_part_id }}-fits"
hx-swap="outerHTML"
hx-confirm="Delete this fit?">Delete</button>
</div>
</div>
</td>
{% endif %}
</tr>
+61 -35
View File
@@ -4,36 +4,46 @@
{% if message %}<p class="alert alert-success">{{ message }}</p>{% endif %}
{% if error %}<p class="alert alert-error">{{ error }}</p>{% endif %}
{% for view in fit_views %}
{% with fit=view.fit, own_ap=view.own_ap, other_ap=view.other_ap, other_part=view.other_part %}
{% include "admin/_augmented_part_fit_row.html" %}
{% endwith %}
{% else %}
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No fits recorded yet.</p>
{% endfor %}
{% set add_form_id = "ap-" ~ augmented_part_id ~ "-fit-add-form" %}
{% if can_write %}
<div class="row-actions" style="margin:0.5rem 0">
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/recompute"
hx-target="#ap-{{ augmented_part_id }}-fits" hx-swap="outerHTML">
<button type="submit" class="btn btn-ghost">Recompute possible fits</button>
</form>
</div>
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits"
<form id="{{ add_form_id }}"
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits"
hx-target="#ap-{{ augmented_part_id }}-fits"
hx-swap="outerHTML"
class="inline-edit" style="margin-top:0.5rem">
<label>This part's point
<select name="own_attachment_point_id" required>
hx-swap="outerHTML"></form>
{% endif %}
<table class="table fit-table">
<thead>
<tr>
<th>Type</th>
<th>This part's point</th>
<th></th>
<th>Fits with part</th>
<th>Notes</th>
{% if can_write %}<th></th>{% endif %}
</tr>
</thead>
<tbody>
{% if can_write %}
<tr class="fit-add-row">
<td>
<select form="{{ add_form_id }}" name="label">
<option value="exact" selected>Exact</option>
<option value="possible">Possible</option>
<option value="none">None</option>
</select>
</td>
<td>
<select form="{{ add_form_id }}" name="own_attachment_point_id" required>
<option value="">— select —</option>
{% for p in own_points %}
<option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option>
{% endfor %}
</select>
</label>
<label>Fits with part
<select name="other_augmented_part_id" required
</td>
<td class="fit-item-arrow">↔</td>
<td>
<select form="{{ add_form_id }}" name="other_augmented_part_id" required
hx-get="/admin/augmented-parts/{{ augmented_part_id }}/fits/other-points"
hx-target="#ap-{{ augmented_part_id }}-fit-other-points"
hx-trigger="change"
@@ -43,19 +53,35 @@
<option value="{{ p.id }}">{{ p.description | truncate(40) }}</option>
{% endfor %}
</select>
</label>
<div id="ap-{{ augmented_part_id }}-fit-other-points"></div>
<label>Fit type
<select name="label">
<option value="exact" selected>Exact</option>
<option value="possible">Possible</option>
<option value="none">None</option>
</select>
</label>
<label>Note (optional)
<input type="text" name="note" placeholder="Add a note…">
</label>
<button type="submit" class="btn btn-primary">Add fit</button>
</td>
<td>
<input form="{{ add_form_id }}" type="text" name="note" placeholder="Add a note…">
</td>
<td>
<button type="submit" form="{{ add_form_id }}" class="btn btn-primary btn-sm">Add fit</button>
</td>
</tr>
{% endif %}
{% for view in fit_views %}
{% with fit=view.fit, own_ap=view.own_ap, other_ap=view.other_ap, other_part=view.other_part %}
{% include "admin/_augmented_part_fit_row.html" %}
{% endwith %}
{% else %}
<tr>
<td colspan="{{ 6 if can_write else 5 }}" class="muted" style="font-size:0.85rem">No fits recorded yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if can_write %}
<div class="row-actions" style="margin:0.5rem 0">
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/recompute"
hx-target="#ap-{{ augmented_part_id }}-fits" hx-swap="outerHTML">
<button type="submit" class="btn btn-ghost">Recompute possible fits</button>
</form>
</div>
{% endif %}
</div>
@@ -7,11 +7,24 @@
<td>{{ ap.scope_model.name if ap.scope_model else '—' }}</td>
<td>{{ ap.part_type.name }}</td>
<td>{{ ap.sub_part_type.name if ap.sub_part_type else '—' }}</td>
<td>{% if ap.archived %}<span class="tag">Archived</span>{% else %}—{% endif %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Edit</button>
{% if ap.archived %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ ap.id }}/unarchive"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Unarchive</button>
{% else %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ ap.id }}/archive"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML"
hx-confirm="Archive {{ ap.sku or ap.description }}?">Archive</button>
{% endif %}
<button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
@@ -1,5 +1,5 @@
<tr id="augmented-part-{{ ap.id }}">
<td colspan="9">
<td colspan="10">
<form id="augmented-part-edit-form-{{ ap.id }}"
hx-post="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
@@ -12,7 +12,7 @@
hx-swap="outerHTML">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
<input type="text" name="description" placeholder="Filter by description or SKU…" value="{{ filter_description or '' }}">
<select name="colour_id">
<option value="">All colours</option>
{% for pc in part_colours %}
@@ -43,6 +43,7 @@
<option value="{{ spt.id }}" {{ "selected" if filter_sub_part_type_id and filter_sub_part_type_id|int == spt.id }}>{{ spt.name }}</option>
{% endfor %}
</select>
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
</form>
</div>
@@ -12,6 +12,7 @@
{{ sort_th("scope_model", "Model", sort_headers) }}
{{ sort_th("part_type", "Type", sort_headers) }}
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
<th>Archived</th>
<th></th>
</tr>
</thead>
@@ -19,7 +20,7 @@
{% for ap in augmented_parts %}
{% include "admin/_augmented_part_row.html" %}
{% else %}
<tr><td colspan="9" class="muted">No augmented parts recorded.</td></tr>
<tr><td colspan="10" class="muted">No augmented parts recorded.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1,14 @@
{% if total %}
<p style="color:var(--color-danger,#c00)">
Purge complete: {{ total }} row(s) deleted.
</p>
<ul class="muted">
{% for label, n in counts.items() %}
{% if n %}
<li>{{ label }}: {{ n }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p class="muted">Nothing to purge — the database was already clean.</p>
{% endif %}
@@ -1,12 +1,12 @@
{% if points %}
<select name="other_attachment_point_id" required>
<select name="other_attachment_point_id" form="{{ form_id }}" required>
<option value="">— select —</option>
{% for p in points %}
<option value="{{ p.id }}">{{ p.attachment_point_name.name }}</option>
{% endfor %}
</select>
{% else %}
<input type="hidden" name="other_attachment_point_id" value="">
<input type="hidden" name="other_attachment_point_id" value="" form="{{ form_id }}">
<p class="muted" style="font-size:0.85rem;margin:0">
This part has no attachment points yet — one matching yours will be created automatically.
</p>
@@ -13,7 +13,7 @@
hx-swap="outerHTML">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
<input type="text" name="description" placeholder="Filter by description or SKU…" value="{{ filter_description or '' }}">
</form>
</div>
@@ -38,6 +38,7 @@
<option value="unc_thread">UNC Thread</option>
<option value="clamp_connection">Clamp Connection</option>
<option value="static_connection">Static Connection</option>
<option value="manual">Manual</option>
</select>
</label>
<div id="add-connection-options"></div>
@@ -6,11 +6,12 @@
<main class="container">
<p><a href="/admin/augmented-parts" class="btn btn-ghost">← Augmented Parts</a></p>
<h1>{{ ap.description }}</h1>
<h1> {% if ap.sku %} {{ ap.sku }} {% else %} {{ ap.description }} {% endif %} </h1>
<p class="muted">
<span class="tag">{{ ap.part_type.name }}</span>
{% if ap.sub_part_type %}<span class="tag">{{ ap.sub_part_type.name }}</span>{% endif %}
{% if ap.sku %}· SKU {{ ap.sku }}{% endif %}
{% if ap.archived %}<span class="tag">Archived</span>{% endif %}
{% if ap.description %}{{ ap.description }}{% endif %}
</p>
<div class="grid">
@@ -50,7 +51,6 @@
{{ scope_info.objective_lens_diameter_mm }} mm
{% else %}—{% endif %}
</dd>
<dt>Objective thread</dt><dd>{{ scope_info.objective_thread_pitch or "—" }}</dd>
</dl>
</div>
{% endif %}
+1 -1
View File
@@ -56,7 +56,7 @@
<div class="card">
<h2>Sync with Imported Parts</h2>
<p class="muted">Creates Augmented Part entries for new Imported Parts, and removes entries for deleted Imported Parts.</p>
<p class="muted">Creates Augmented Part entries for new Imported Parts, and archives entries for deleted Imported Parts.</p>
<div class="row-actions">
<form hx-post="/admin/augmented-parts/sync" hx-target="#augmented-part-list" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Sync now</button>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Danger Zone · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Danger Zone</h1>
<div class="card" style="border: 1px solid var(--color-danger, #c00)">
<h2>Purge database</h2>
<p>
Permanently deletes every part, attachment point, fit, and imported
part in the database — manufacturers, scope models, ISO/UNC threads,
clamp/static connections, attachment point names, augmented parts,
scope info, attachment points, fits, fit notes, and imported parts.
</p>
<p>
<strong>Not</strong> deleted: users, roles &amp; permissions, refresh
tokens, the audit log, the OData connection configuration, and part
taxonomy (part colours, part types, sub part types).
</p>
<p class="muted">This cannot be undone. Take a database backup first if you might need this data again.</p>
<button class="btn btn-danger"
hx-post="/admin/danger-zone/purge"
hx-target="#purge-result"
hx-swap="innerHTML"
hx-confirm="This will PERMANENTLY delete all parts, taxonomy, attachment points, fits, and imported parts. Users and the OData configuration are kept. This cannot be undone. Continue?">
Purge all part/fit data
</button>
<div id="purge-result" style="margin-top:1rem"></div>
</div>
</main>
{% endblock %}
+1 -3
View File
@@ -29,11 +29,9 @@
<button class="btn btn-primary"
hx-post="/admin/imported-parts/refresh"
hx-target="#imported-parts-section"
hx-swap="outerHTML"
hx-indicator="#refresh-indicator">
hx-swap="outerHTML">
Refresh from OData
</button>
<span id="refresh-indicator" class="htmx-indicator muted"> Loading…</span>
</div>
{% include "admin/_imported_parts_table.html" %}
+2
View File
@@ -35,6 +35,8 @@
<li><a href="/redoc" target="_blank">API reference (ReDoc)</a></li>
</ul>
</div>
{% include "_dashboard_sync_card.html" %}
</div>
</main>
{% endblock %}
+47 -2
View File
@@ -7,7 +7,8 @@
<p class="muted">
Every scope on record, with the accessories that fit it. Each part-type
column lists matching parts; <span class="fit-badge fit-badge-exact">Exact</span>
is a confirmed fit, <span class="fit-badge fit-badge-possible">Possible</span> a candidate.
is a confirmed fit{% if not only_exact %}, <span class="fit-badge fit-badge-possible">Possible</span> a candidate{% endif %}.
{% if only_exact %}<a href="/login">Sign in</a> to also see unconfirmed candidate fits.{% endif %}
</p>
<div class="filter-bar">
@@ -15,7 +16,7 @@
hx-target="#scopes-table-card"
hx-trigger="input delay:300ms, change"
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 '' }}">
<select name="manufacturer_id">
<option value="">All manufacturers</option>
@@ -44,6 +45,50 @@
</form>
</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" %}
</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 %}
+260 -97
View File
@@ -1,8 +1,5 @@
"""HTMX-driven admin dashboard for users, roles, and thread standards."""
import math
import urllib.parse
from fastapi import (
APIRouter,
Depends,
@@ -17,9 +14,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
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 augmented_part as augmented_part_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_config as imported_part_config_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 scope_info as scope_info_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 sub_part_type as sub_part_type_crud
from app.crud import unc_thread as unc_thread_crud
@@ -43,10 +40,13 @@ from app.models.fit import FitLabel
from app.models.unc_thread import UncSeries
from app.models.user import User
from app.services import augmented_part_sync as augmented_part_sync_service
from app.services import danger_zone as danger_zone_service
from app.services import fit_matching as fit_matching_service
from app.services import jobs
from app.services import odata as odata_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
router = APIRouter(prefix="/admin", include_in_schema=False)
@@ -54,42 +54,6 @@ router = APIRouter(prefix="/admin", include_in_schema=False)
_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(
base_path: str,
filter_params: dict,
@@ -131,6 +95,16 @@ def web_require(*perms: str):
return checker
async def web_require_superuser(user: User = Depends(get_current_web_user)) -> User:
"""Web dependency: superusers only, no permission-string bypass."""
if not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return user
async def _role_perm_ctx(db: AsyncSession) -> dict:
all_perms = await role_crud.list_permissions(db)
scopes = sorted({p.name.split(":")[0] for p in all_perms})
@@ -1355,6 +1329,19 @@ async def save_imported_part_config(
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)
async def refresh_imported_parts(
request: Request,
@@ -1362,45 +1349,94 @@ async def refresh_imported_parts(
actor: User = Depends(web_require("imported_parts:write")),
):
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:
return templates.TemplateResponse(
request,
"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:
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,
actor=actor_row,
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."
description=(
f"OData sync: {created} created, {updated} updated "
f"({len(records)} total records)"
),
)
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(
request,
"admin/_imported_parts_table.html",
await _imported_table_ctx(sync_error=f"Sync failed: {exc}"),
await _imported_parts_table_ctx(
db, sync_error="Refresh job not found — it may have expired."
),
)
if job.status == "running":
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _IMPORTED_PARTS_REFRESH_TARGET_ID,
"poll_url": f"/admin/imported-parts/refresh/jobs/{job_id}",
"job": job,
},
)
return templates.TemplateResponse(
request,
"admin/_imported_parts_table.html",
await _imported_parts_table_ctx(
db,
sync_result=job.message if job.status == "done" else None,
sync_error=job.message if job.status == "error" else None,
),
)
@@ -1500,13 +1536,12 @@ async def upload_scope_import(
data = await file.read()
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # noqa: BLE001
job.status = "error"
job.message = f"Could not read spreadsheet: {exc}"
await jobs.mark_error(job_id, f"Could not read spreadsheet: {exc}")
return
actor_row = await user_crud.get(db, actor_id)
await audit_crud.log(
@@ -1521,10 +1556,16 @@ async def upload_scope_import(
f"{result.errors} errors"
),
)
job.status = "done"
job.result = result
await jobs.mark_done(
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(
request, "admin/_job_progress.html", _scope_import_progress_ctx(job)
)
@@ -1534,9 +1575,10 @@ async def upload_scope_import(
async def scope_import_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse(
request,
@@ -1618,6 +1660,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
),
"sort": "description",
"dir": "asc",
"include_archived": False,
}
@@ -1626,17 +1669,24 @@ async def augmented_parts_page(
request: Request,
sort: str = "description",
dir: str = "asc",
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read")),
):
ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
total = await augmented_part_crud.count_filtered(
db, include_archived=include_archived
)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
db, sort=sort, direction=dir, limit=_PAGE_SIZE
db,
sort=sort,
direction=dir,
include_archived=include_archived,
limit=_PAGE_SIZE,
)
ctx["pagination"] = _paginate(
"/admin/augmented-parts/filter",
{"sort": sort, "dir": dir},
{"sort": sort, "dir": dir, "include_archived": include_archived},
1,
_PAGE_SIZE,
total,
@@ -1644,7 +1694,7 @@ async def augmented_parts_page(
)
ctx["sort_headers"] = _sort_headers(
"/admin/augmented-parts/filter",
{},
{"include_archived": include_archived},
_AUGMENTED_PARTS_SORT_COLUMNS,
sort,
dir,
@@ -1652,6 +1702,7 @@ async def augmented_parts_page(
)
ctx["sort"] = sort
ctx["dir"] = dir
ctx["include_archived"] = include_archived
ctx["user"] = user
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@@ -1707,13 +1758,12 @@ async def sync_augmented_parts(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await augmented_part_sync_service.sync(db)
except Exception as exc:
job.status = "error"
job.message = f"Sync failed: {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(
@@ -1723,22 +1773,25 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.deleted} deleted, "
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
job.status = "done"
job.message = (
f"Sync complete: {result.created} created, {result.deleted} deleted"
await jobs.mark_done(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.archived} archived"
+ (
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(
request,
"admin/_job_progress.html",
@@ -1757,7 +1810,7 @@ async def augmented_parts_sync_job_status(
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
form_ctx = await _augmented_part_form_ctx(db)
return templates.TemplateResponse(
@@ -1802,6 +1855,7 @@ async def filter_augmented_parts(
scope_model_id: str = "",
part_type_id: str = "",
sub_part_type_id: str = "",
include_archived: bool = False,
sort: str = "description",
dir: str = "asc",
page: int = 1,
@@ -1822,6 +1876,7 @@ async def filter_augmented_parts(
scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
)
filter_params = {
"description": description,
@@ -1830,6 +1885,7 @@ async def filter_augmented_parts(
"scope_model_id": scope_model_id,
"part_type_id": part_type_id,
"sub_part_type_id": sub_part_type_id,
"include_archived": include_archived,
}
pagination = _paginate(
"/admin/augmented-parts/filter",
@@ -1847,6 +1903,7 @@ async def filter_augmented_parts(
scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
sort=sort,
direction=dir,
skip=pagination["skip"],
@@ -1869,6 +1926,7 @@ async def filter_augmented_parts(
"sort_headers": sort_headers,
"sort": sort,
"dir": dir,
"include_archived": include_archived,
},
)
@@ -2166,6 +2224,60 @@ async def delete_augmented_part(
return Response(status_code=status.HTTP_200_OK)
async def _set_augmented_part_archived(
request: Request,
augmented_part_id: int,
archived: bool,
db: AsyncSession,
actor: User,
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
ap = await augmented_part_crud.set_archived(db, ap, archived)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_update(
"AugmentedPart", augmented_part_id, {"archived": archived}
),
)
return templates.TemplateResponse(
request, "admin/_augmented_part_row.html", {"ap": ap}
)
@router.post(
"/augmented-parts/{augmented_part_id}/archive", response_class=HTMLResponse
)
async def archive_augmented_part(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
return await _set_augmented_part_archived(
request, augmented_part_id, True, db, actor
)
@router.post(
"/augmented-parts/{augmented_part_id}/unarchive", response_class=HTMLResponse
)
async def unarchive_augmented_part(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
return await _set_augmented_part_archived(
request, augmented_part_id, False, db, actor
)
def _can_write_attachment_points(user: User) -> bool:
return user.is_superuser or "attachment_points:write" in user.permissions
@@ -2275,7 +2387,7 @@ async def edit_augmented_part_attachment_point(
elif apoint.static_connection_id:
ctx["connection_type"] = "static_connection"
else:
ctx["connection_type"] = ""
ctx["connection_type"] = "manual"
return templates.TemplateResponse(
request, "admin/_augmented_part_ap_row_edit.html", ctx
)
@@ -2472,7 +2584,7 @@ async def recompute_augmented_part_fits(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_for_part(
db, augmented_part_id
@@ -2489,10 +2601,11 @@ async def recompute_augmented_part_fits(
f"{result.created} created, {result.removed} removed"
),
)
job.status = "done"
job.message = f"{result.created} created, {result.removed} removed."
await jobs.mark_done(
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(
request,
"admin/_job_progress.html",
@@ -2516,7 +2629,7 @@ async def augmented_part_fits_recompute_job_status(
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
ctx = await _augmented_part_fits_ctx(
db,
@@ -2569,7 +2682,12 @@ async def fit_other_points(
else []
)
return templates.TemplateResponse(
request, "admin/_fit_other_points_select.html", {"points": points}
request,
"admin/_fit_other_points_select.html",
{
"points": points,
"form_id": f"ap-{augmented_part_id}-fit-add-form",
},
)
@@ -2763,7 +2881,7 @@ async def recompute_all_fits(
):
actor_id = actor.id
async def _run(job: jobs.Job) -> None:
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
result = await fit_matching_service.recompute_all(db)
actor_row = await user_crud.get(db, actor_id)
@@ -2778,13 +2896,15 @@ async def recompute_all_fits(
f"{result.removed} removed"
),
)
job.status = "done"
job.message = (
await jobs.mark_done(
job_id,
message=(
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(
request,
"admin/_job_progress_inline.html",
@@ -2802,9 +2922,10 @@ async def recompute_all_fits(
async def recompute_all_fits_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("fits:write")),
):
job = jobs.get(job_id)
job = await jobs.get(db, job_id)
if job is None:
return HTMLResponse("Recompute job not found — it may have expired.")
if job.status == "running":
@@ -3121,7 +3242,7 @@ def _ap_connection_label(ap) -> str:
return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD"
return "—"
return "Manual"
@router.get("/attachment-points", response_class=HTMLResponse)
@@ -3218,7 +3339,7 @@ async def edit_attachment_point_row(
elif ap.static_connection_id:
ctx["connection_type"] = "static_connection"
else:
ctx["connection_type"] = ""
ctx["connection_type"] = "manual"
return templates.TemplateResponse(
request, "admin/_attachment_point_row_edit.html", ctx
)
@@ -3598,3 +3719,45 @@ async def audit_log_page(
"admin/audit_log.html",
{"user": user, "entries": await audit_crud.list_recent(db)},
)
# --- Danger Zone ---------------------------------------------------------------
@router.get("/danger-zone", response_class=HTMLResponse)
async def danger_zone_page(
request: Request,
user: User = Depends(web_require_superuser),
):
return templates.TemplateResponse(
request,
"admin/danger_zone.html",
{"user": user},
)
@router.post("/danger-zone/purge", response_class=HTMLResponse)
async def purge_database(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require_superuser),
):
counts = await danger_zone_service.purge_domain_data(db)
total = sum(counts.values())
breakdown = ", ".join(f"{label} {n}" for label, n in counts.items() if n)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Database",
resource_id=None,
description=(
f"Purged all domain data via Danger Zone ({total} row(s) deleted"
+ (f": {breakdown})" if breakdown else ")")
),
)
return templates.TemplateResponse(
request,
"admin/_danger_zone_result.html",
{"total": total, "counts": counts},
)
+353 -7
View File
@@ -1,18 +1,33 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.database import AsyncSessionLocal, get_db
from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_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 part_type as part_type_crud
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.deps import get_optional_web_user
from app.models.user import User
from app.services import augmented_part_sync as augmented_part_sync_service
from app.services import fit_matching as fit_matching_service
from app.services import jobs
from app.services import ldap as ldap_service
from app.services import odata as odata_service
from app.services import scope_guide as scope_guide_service
from app.services.auth import issue_token_pair
from app.web.admin import web_require
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
_SYNC_CARD_ID = "dashboard-sync-card"
def _safe_next(next_url: str) -> str:
"""Return next_url only if it's a safe same-site path, else "/".
@@ -36,17 +51,344 @@ def _set_auth_cookie(response: Response, access_token: str) -> None:
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
max_age=settings.session_timeout_hours * 3600,
path="/",
)
async def _dashboard_stats(db: AsyncSession) -> dict:
scope_type = await part_type_crud.get_by_name(
db, scope_guide_service.SCOPE_TYPE_NAME
)
scope_count = (
await augmented_part_crud.count_filtered(db, part_type_id=scope_type.id)
if scope_type is not None
else 0
)
return {
"scope_count": scope_count,
"skipped_count": await augmented_part_crud.count_unlinked_filtered(db),
}
def _dashboard_permissions(user: User) -> dict:
return {
"can_refresh_odata": user.is_superuser
or "imported_parts:write" in user.permissions,
"can_sync_augmented_parts": user.is_superuser
or "augmented_parts:write" in user.permissions,
"can_recompute_fits": user.is_superuser or "fits:write" in user.permissions,
}
async def _sync_card_ctx(
db: AsyncSession,
user: User,
status_message: str | None = None,
status_error: str | None = None,
) -> dict:
return {
**(await _dashboard_stats(db)),
**_dashboard_permissions(user),
"status_message": status_message,
"status_error": status_error,
}
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
user: User = Depends(get_current_web_user),
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_web_user),
):
return templates.TemplateResponse(request, "dashboard.html", {"user": user})
# Anonymous visitors land on the public Scopes page instead of being
# bounced to login; the dashboard itself requires an account.
if user is None:
return RedirectResponse(url="/scopes", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse(
request,
"dashboard.html",
{"user": user, **(await _sync_card_ctx(db, user))},
)
@router.post("/dashboard/refresh-odata", response_class=HTMLResponse)
async def dashboard_refresh_odata(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("imported_parts:write")),
):
config = await imported_part_config_crud.get(db)
if not config or not config.odata_url:
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db, actor, status_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:
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": _SYNC_CARD_ID,
"poll_url": f"/dashboard/refresh-odata/jobs/{job.id}",
"job": job,
},
)
@router.get("/dashboard/refresh-odata/jobs/{job_id}", response_class=HTMLResponse)
async def dashboard_refresh_odata_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("imported_parts:write")),
):
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db, actor, status_error="Refresh job not found — it may have expired."
),
)
if job.status == "running":
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _SYNC_CARD_ID,
"poll_url": f"/dashboard/refresh-odata/jobs/{job_id}",
"job": job,
},
)
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db,
actor,
status_message=job.message if job.status == "done" else None,
status_error=job.message if job.status == "error" else None,
),
)
@router.post("/dashboard/sync-augmented-parts", response_class=HTMLResponse)
async def dashboard_sync_augmented_parts(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
actor_id = actor.id
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await augmented_part_sync_service.sync(db)
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="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
await jobs.mark_done(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"
if result.skipped
else ""
)
+ "."
),
)
job = await jobs.start("augmented_part_sync", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _SYNC_CARD_ID,
"poll_url": f"/dashboard/sync-augmented-parts/jobs/{job.id}",
"job": job,
},
)
@router.get(
"/dashboard/sync-augmented-parts/jobs/{job_id}", response_class=HTMLResponse
)
async def dashboard_sync_augmented_parts_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db, actor, status_error="Sync job not found — it may have expired."
),
)
if job.status == "running":
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _SYNC_CARD_ID,
"poll_url": f"/dashboard/sync-augmented-parts/jobs/{job_id}",
"job": job,
},
)
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db,
actor,
status_message=job.message if job.status == "done" else None,
status_error=job.message if job.status == "error" else None,
),
)
@router.post("/dashboard/recompute-fits", response_class=HTMLResponse)
async def dashboard_recompute_fits(
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
actor_id = actor.id
async def _run(job_id: str) -> None:
async with AsyncSessionLocal() as db:
try:
result = await fit_matching_service.recompute_all(db)
except Exception as exc:
await jobs.mark_error(job_id, f"Recompute failed: {exc}")
return
actor_row = await user_crud.get(db, actor_id)
await audit_crud.log(
db,
actor=actor_row,
action="update",
resource_type="Fit",
resource_id=None,
description=(
f"Recomputed fits for all parts: {result.created} created, "
f"{result.removed} removed"
),
)
await jobs.mark_done(
job_id,
message=(
f"Recompute complete: {result.created} created, "
f"{result.removed} removed."
),
)
job = await jobs.start("fit_recompute_all", _run)
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _SYNC_CARD_ID,
"poll_url": f"/dashboard/recompute-fits/jobs/{job.id}",
"job": job,
},
)
@router.get("/dashboard/recompute-fits/jobs/{job_id}", response_class=HTMLResponse)
async def dashboard_recompute_fits_job_status(
request: Request,
job_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("fits:write")),
):
job = await jobs.get(db, job_id)
if job is None:
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db,
actor,
status_error="Recompute job not found — it may have expired.",
),
)
if job.status == "running":
return templates.TemplateResponse(
request,
"admin/_job_progress.html",
{
"container_id": _SYNC_CARD_ID,
"poll_url": f"/dashboard/recompute-fits/jobs/{job_id}",
"job": job,
},
)
return templates.TemplateResponse(
request,
"_dashboard_sync_card.html",
await _sync_card_ctx(
db,
actor,
status_message=job.message if job.status == "done" else None,
status_error=job.message if job.status == "error" else None,
),
)
@router.get("/login", response_class=HTMLResponse)
@@ -79,7 +421,9 @@ async def login_submit(
},
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(
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
)
@@ -110,7 +454,9 @@ async def login_ldap_submit(
},
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(
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,
}
+139 -9
View File
@@ -5,29 +5,67 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX
search/filter over scope attributes and required accessory fits.
"""
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
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 part_type as part_type_crud
from app.crud import scope_model as scope_model_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.deps import get_optional_web_user
from app.models.user import User
from app.services import scope_guide as scope_guide_service
from app.web.admin import web_require
from app.web.pagination import paginate
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
_PAGE_SIZE = 50
_TABLE_TARGET = "#scopes-table-card"
_FILTER_PATH = "/scopes/filter"
_REQUIRED_PERMS = {"augmented_parts:read", "fits:read"}
def _to_int(value: str) -> int | None:
return int(value) if value.strip() else None
async def _build(db: AsyncSession, filters: dict) -> dict:
"""Run the guide query for the given raw (string) filter values."""
def _can_write_scopes(user: User | None) -> bool:
return user is not None and (
user.is_superuser or "augmented_parts:write" in user.permissions
)
async def _optional_scopes_user(
user: User | None = Depends(get_optional_web_user),
) -> User | None:
"""Anyone may view the scopes page; logged-in users still need the read perms.
Anonymous visitors are allowed through (and get an exact-fits-only view —
see ``only_exact`` in ``scope_guide.build``); a logged-in user missing the
permissions gets the usual 403, matching ``web_require``'s behaviour.
"""
if user is not None and not (
user.is_superuser or _REQUIRED_PERMS.issubset(user.permissions)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return user
async def _build(db: AsyncSession, filters: dict, only_exact: bool = False) -> dict:
"""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(
db,
description=filters["description"],
@@ -35,9 +73,30 @@ async def _build(db: AsyncSession, filters: dict) -> dict:
scope_model_id=_to_int(filters["scope_model_id"]),
sub_part_type_id=_to_int(filters["sub_part_type_id"]),
fits_part_type_id=_to_int(filters["fits_part_type_id"]),
only_exact=only_exact,
)
async def _paginated_guide(
db: AsyncSession,
filters: dict,
page: int,
page_size: int,
only_exact: bool = False,
) -> 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, only_exact=only_exact)
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:
"""Dropdown option lists for the filter bar."""
scope_type = await part_type_crud.get_by_name(
@@ -64,7 +123,7 @@ async def scopes_page(
sub_part_type_id: str = "",
fits_part_type_id: str = "",
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
user: User | None = Depends(_optional_scopes_user),
):
filters = {
"description": description,
@@ -73,11 +132,22 @@ async def scopes_page(
"sub_part_type_id": sub_part_type_id,
"fits_part_type_id": fits_part_type_id,
}
guide = await _build(db, filters)
only_exact = user is None
guide, pagination = await _paginated_guide(
db, filters, 1, _PAGE_SIZE, only_exact=only_exact
)
return templates.TemplateResponse(
request,
"scopes.html",
{"user": user, "filters": filters, **guide, **(await _filter_options(db))},
{
"user": user,
"filters": filters,
"can_write_scopes": _can_write_scopes(user),
"only_exact": only_exact,
"pagination": pagination,
**guide,
**(await _filter_options(db)),
},
)
@@ -89,8 +159,10 @@ async def scopes_filter(
scope_model_id: str = "",
sub_part_type_id: str = "",
fits_part_type_id: str = "",
page: int = 1,
page_size: int = _PAGE_SIZE,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("augmented_parts:read", "fits:read")),
user: User | None = Depends(_optional_scopes_user),
):
filters = {
"description": description,
@@ -99,5 +171,63 @@ async def scopes_filter(
"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)
only_exact = user is None
guide, pagination = await _paginated_guide(
db, filters, page, page_size, only_exact=only_exact
)
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),
},
)
+34
View File
@@ -0,0 +1,34 @@
- [ ] AugmentedParts Listing
- [x] Add an `archived` attribute that indicates the part should not show up in default searches
- [x] Admins and users with permissions can opt into seeing archived parts
- [ ] Add bulk "mark archived"
- [ ] Scope Import
- [ ] Test scopes with explicit fits populated
- [ ] Dashboard
- [ ] Make this more useful.
- [ ] Number of
- [ ] Count of Augmented Parts by Type
- [ ] Skipped parts from last Sync
- [ ] Handy buttons to run the import / sync / recompute actions
- [x] AugmentedPart Details
- [x] Title should be the SKU
- [x] Add new inference for attachment point "Clamp"
- [x] Appears as `(44.5-47.5mm)` in the description
- [x] Better layout for Scopes page
- [x] Layout is too wide, requiring side-scrolling
- [x] Use product IDs instead of full descriptions
- [x] Descriptions available as a mouseover hint
- [x] Fit type (exact/possible/none) represented as a halo rather than a label
- [X] ScopeInfo table
- [X] Remove "objective_thread", as it's not populated and confusing
- [x] Part Import
- [x] Example query of `ZC5000` should return multiple parts
- [x] Filter text should search SKU and description
- [x] Attachment Points
- [x] Remove objective / occular labels and matching logic
+1
View File
@@ -2,6 +2,7 @@
set -e
# Idempotent: creates perms/roles/superuser only if missing.
uv run alembic upgrade head
uv run python -m scripts.seed
exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
+1 -1
View File
@@ -17,7 +17,7 @@ from app.crud import role as role_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.crud import user as user_crud
ATTACHMENT_POINT_NAMES = ["Objective", "Occular"]
ATTACHMENT_POINT_NAMES = ["Objective", "Ocular", "Rail"]
PART_TYPES = ["Flip Cover", "ARD", "Polarizer", "Adapter Ring", "Scope"]
+88
View File
@@ -2,7 +2,11 @@ import asyncio
import re
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.models.attachment_point import AttachmentPoint
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/[^"]+)"')
@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"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@@ -39,6 +62,7 @@ async def test_scope_import_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 with db_sessionmaker() as db:
perms = seeded["perms"]
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
):
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(
@@ -93,6 +118,7 @@ async def test_fit_recompute_for_part_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 with db_sessionmaker() as db:
apn = AttachmentPointName(name="Objective")
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
):
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(
@@ -154,3 +181,64 @@ async def test_fit_recompute_all_runs_in_background(
final_body = await _poll_until_done(client, match.group(1))
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
+34
View File
@@ -90,6 +90,23 @@ async def test_augmented_parts_filter_sorts_by_sku_asc(
]
@pytest.mark.asyncio
async def test_augmented_parts_filter_matches_by_sku(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_augmented_parts(db)
await make_user("reader2b@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader2b@example.com")
resp = await client.get("/admin/augmented-parts/filter?description=SKU-B")
assert resp.status_code == 200
body = resp.text
assert "Charlie scope" in body
assert "Alpha scope" not in body
assert "Bravo scope" not in body
@pytest.mark.asyncio
async def test_imported_parts_page_sorts_by_description_desc(
client, seeded, make_user, db_sessionmaker
@@ -109,6 +126,23 @@ async def test_imported_parts_page_sorts_by_description_desc(
]
@pytest.mark.asyncio
async def test_imported_parts_filter_matches_by_inventory_id(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_imported_parts(db)
await make_user("reader4@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader4@example.com")
resp = await client.get("/admin/imported-parts/filter?description=INV-3")
assert resp.status_code == 200
body = resp.text
assert "Bravo widget" in body
assert "Alpha widget" not in body
assert "Charlie widget" not in body
@pytest.mark.asyncio
async def test_list_filtered_sorts_by_joined_colour_name(db_sessionmaker):
async with db_sessionmaker() as db:
+106
View File
@@ -0,0 +1,106 @@
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
async def _seed_part(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Flip Cover")
apn = AttachmentPointName(name="Mount")
iso = IsoThread(diameter_mm=52.0, pitch_mm=0.75)
db.add_all([manufacturer, colour, part_type, apn, iso])
await db.flush()
part = AugmentedPart(
description="Flip cover",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add(part)
await db.flush()
await db.commit()
return part.id, apn.id, iso.id
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def test_create_manual_attachment_point_with_no_connection_succeeds(
client, seeded, make_user, db_sessionmaker
):
await make_user("root@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, _ = await _seed_part(db)
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["iso_thread_id"] is None
assert body["unc_thread_id"] is None
assert body["clamp_connection_id"] is None
assert body["static_connection_id"] is None
async def test_create_attachment_point_with_two_connections_rejected(
client, seeded, make_user, db_sessionmaker
):
await make_user("root2@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root2@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, iso_id = await _seed_part(db)
from app.crud import static_connection as static_connection_crud
static = await static_connection_crud.create(db, od_mm=40.0, od_in=1.57)
static_id = static.id
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
"iso_thread_id": iso_id,
"static_connection_id": static_id,
},
headers=headers,
)
assert resp.status_code == 400
assert "at most one" in resp.json()["detail"].lower()
async def test_create_attachment_point_with_one_connection_still_works(
client, seeded, make_user, db_sessionmaker
):
await make_user("root3@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root3@example.com')}"}
async with db_sessionmaker() as db:
part_id, apn_id, iso_id = await _seed_part(db)
resp = await client.post(
"/api/attachment-points",
json={
"augmented_part_id": part_id,
"attachment_point_name_id": apn_id,
"iso_thread_id": iso_id,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
assert resp.json()["iso_thread_id"] == iso_id
+257
View File
@@ -0,0 +1,257 @@
import pytest
from app.crud import augmented_part as augmented_part_crud
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.services import augmented_part_sync
async def _seed_reference_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Scope")
db.add_all([manufacturer, colour, part_type])
await db.flush()
return manufacturer, colour, part_type
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
@pytest.mark.asyncio
async def test_list_and_count_exclude_archived_by_default(db_sessionmaker):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
active = AugmentedPart(
description="Active part",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
archived = AugmentedPart(
description="Archived part",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
archived=True,
)
db.add_all([active, archived])
await db.commit()
assert [p.description for p in await augmented_part_crud.list_all(db)] == [
"Active part"
]
assert await augmented_part_crud.count_filtered(db) == 1
all_descs = {
p.description
for p in await augmented_part_crud.list_all(db, include_archived=True)
}
assert all_descs == {"Active part", "Archived part"}
assert await augmented_part_crud.count_filtered(db, include_archived=True) == 2
@pytest.mark.asyncio
async def test_set_archived_toggles_flag(db_sessionmaker):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Widget",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add(ap)
await db.commit()
ap = await augmented_part_crud.set_archived(db, ap, True)
assert ap.archived is True
ap = await augmented_part_crud.set_archived(db, ap, False)
assert ap.archived is False
@pytest.mark.asyncio
async def test_sync_archives_part_only_when_all_backing_imports_are_gone(
db_sessionmaker,
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ip_x = ImportedPart(InventoryID="INV-X", Description="Widget X")
ip_y = ImportedPart(InventoryID="INV-Y", Description="Widget Y")
db.add_all([ip_x, ip_y])
await db.flush()
# One AugmentedPart backed by two ImportedParts.
merged = AugmentedPart(
description="Merged widget",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
imported_parts=[ip_x, ip_y],
)
db.add(merged)
await db.commit()
merged_id = merged.id
# Delete one backing ImportedPart directly (simulating an external feed
# change) and sync: the AugmentedPart still has INV-Y, so it survives.
await db.delete(ip_x)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.archived == 0
refreshed = await augmented_part_crud.get(db, merged_id)
assert refreshed.archived is False
# Now delete the last remaining backing ImportedPart.
await db.delete(ip_y)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.archived == 1
assert result.created == 0
refreshed = await augmented_part_crud.get(db, merged_id)
assert refreshed.archived is True
# Re-running sync is a no-op — it must not double count or explode.
result = await augmented_part_sync.sync(db)
assert result.archived == 0
@pytest.mark.asyncio
async def test_delete_imported_part_via_api_archives_orphaned_augmented_part(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ip = ImportedPart(InventoryID="INV-SOLE", Description="Solo widget")
db.add(ip)
await db.flush()
ap = AugmentedPart(
description="Solo widget",
sku="INV-SOLE",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
imported_parts=[ip],
)
db.add(ap)
await db.commit()
ap_id = ap.id
await make_user("root@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root@example.com')}"}
resp = await client.delete("/api/imported-parts/INV-SOLE", headers=headers)
assert resp.status_code == 204
# ImportedPart is really gone.
assert (
await client.get("/api/imported-parts/INV-SOLE", headers=headers)
).status_code == 404
# AugmentedPart is archived, not deleted — direct fetch still finds it.
get_resp = await client.get(f"/api/augmented-parts/{ap_id}", headers=headers)
assert get_resp.status_code == 200
assert get_resp.json()["archived"] is True
# Hidden from the default list...
default_list = await client.get("/api/augmented-parts", headers=headers)
assert ap_id not in [p["id"] for p in default_list.json()]
# ...but present when archived parts are explicitly requested.
full_list = await client.get(
"/api/augmented-parts?include_archived=true", headers=headers
)
assert ap_id in [p["id"] for p in full_list.json()]
@pytest.mark.asyncio
async def test_patch_can_unarchive_an_augmented_part(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Previously archived",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
archived=True,
)
db.add(ap)
await db.commit()
ap_id = ap.id
await make_user("root2@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root2@example.com')}"}
default_list = await client.get("/api/augmented-parts", headers=headers)
assert ap_id not in [p["id"] for p in default_list.json()]
resp = await client.patch(
f"/api/augmented-parts/{ap_id}", json={"archived": False}, headers=headers
)
assert resp.status_code == 200
assert resp.json()["archived"] is False
default_list = await client.get("/api/augmented-parts", headers=headers)
assert ap_id in [p["id"] for p in default_list.json()]
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_admin_ui_hides_archived_unless_requested_and_can_toggle(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Hidden gem",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
archived=True,
)
db.add(ap)
await db.commit()
ap_id = ap.id
await make_user("admin@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "admin@example.com")
resp = await client.get("/admin/augmented-parts")
assert resp.status_code == 200
assert "Hidden gem" not in resp.text
resp = await client.get("/admin/augmented-parts/filter?include_archived=true")
assert resp.status_code == 200
assert "Hidden gem" in resp.text
resp = await client.post(f"/admin/augmented-parts/{ap_id}/unarchive")
assert resp.status_code == 200
assert "Archived" not in resp.text
resp = await client.get("/admin/augmented-parts")
assert "Hidden gem" in resp.text
resp = await client.post(f"/admin/augmented-parts/{ap_id}/archive")
assert resp.status_code == 200
resp = await client.get("/admin/augmented-parts")
assert "Hidden gem" not in resp.text
-2
View File
@@ -38,7 +38,6 @@ async def _seed_part_with_fit(db):
augmented_part_id=scope.id,
magnification_min=3,
magnification_max=18,
objective_thread_pitch="M52x0.75",
)
)
ap_s = AttachmentPoint(augmented_part_id=scope.id, attachment_point_name_id=apn.id)
@@ -80,7 +79,6 @@ async def test_detail_shows_attributes_and_fits_readonly(
# Attributes
assert "Alpha 3-18x50" in body
assert "ALP-318" in body
assert "M52x0.75" in body # scope info
assert "Objective" in body # attachment point
# Fits are shown (the other part in the fit)
assert "Flip cover 50mm" in body
+164
View File
@@ -0,0 +1,164 @@
import pytest
from app.crud import audit as audit_crud
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.unc_thread import UncSeries, UncThread
async def _seed_domain_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
scope_t = PartType(name="Scope")
cover_t = PartType(name="Flip Cover")
apn = AttachmentPointName(name="Objective")
db.add_all([manufacturer, colour, scope_t, cover_t, apn])
await db.flush()
sub_t = SubPartType(name="Riflescope", part_type_id=scope_t.id)
scope_model = ScopeModel(name="Alpha", manufacturer_id=manufacturer.id)
iso = IsoThread(diameter_mm=52.0, pitch_mm=0.75)
unc = UncThread(diameter_fraction="1/4", tpi=20, series=UncSeries.UNC)
clamp = ClampConnection(min_mm=25.0, max_mm=30.0)
static = StaticConnection(od_mm=50.0, od_in=1.97)
db.add_all([sub_t, scope_model, iso, unc, clamp, static])
await db.flush()
scope = AugmentedPart(
description="Alpha 3-18x50",
sku="ALP-318",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=scope_t.id,
scope_model_id=scope_model.id,
sub_part_type_id=sub_t.id,
)
cover = AugmentedPart(
description="Flip cover 50mm",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=cover_t.id,
)
db.add_all([scope, cover])
await db.flush()
db.add(
AugmentedPartNote(augmented_part_id=scope.id, username="tester", body="note")
)
db.add(
ScopeInfo(augmented_part_id=scope.id, magnification_min=3, magnification_max=18)
)
ap_s = AttachmentPoint(
augmented_part_id=scope.id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
ap_c = AttachmentPoint(
augmented_part_id=cover.id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
db.add_all([ap_s, ap_c])
await db.flush()
fit = Fit(
attachment_point_a_id=ap_s.id,
attachment_point_b_id=ap_c.id,
augmented_part_a_id=scope.id,
augmented_part_b_id=cover.id,
label=FitLabel.POSSIBLE,
)
db.add(fit)
await db.flush()
db.add(FitNote(fit_id=fit.id, username="tester", body="fits fine"))
imported = ImportedPart(InventoryID="INV-1", Description="Alpha 3-18x50")
db.add(imported)
config = ImportedPartConfig(
id=1, odata_url="https://example.test/odata", username="svc"
)
db.add(config)
await db.commit()
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_non_superuser_forbidden(client, seeded, make_user):
# "admin" role holds every permission but is not a superuser.
await make_user("admin@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "admin@example.com")
assert (await client.get("/admin/danger-zone")).status_code == 403
assert (await client.post("/admin/danger-zone/purge")).status_code == 403
@pytest.mark.asyncio
async def test_purge_wipes_domain_data_but_keeps_users_and_odata_config(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
await _seed_domain_data(db)
await make_user("root@example.com", is_superuser=True)
await _web_login(client, "root@example.com")
assert (await client.get("/admin/danger-zone")).status_code == 200
resp = await client.post("/admin/danger-zone/purge")
assert resp.status_code == 200
assert "Purge complete" in resp.text
async with db_sessionmaker() as db:
for Model in (
AugmentedPart,
AugmentedPartNote,
ScopeInfo,
AttachmentPoint,
Fit,
FitNote,
ImportedPart,
ScopeModel,
AttachmentPointName,
IsoThread,
UncThread,
ClampConnection,
StaticConnection,
Manufacturer,
):
rows = (await db.execute(Model.__table__.select())).all()
assert rows == [], f"{Model.__name__} should be empty after purge"
# Kept: users, RBAC, the OData connection config, and part taxonomy.
from app.crud import user as user_crud
assert await user_crud.get_by_email(db, "root@example.com") is not None
config = await db.get(ImportedPartConfig, 1)
assert config is not None
assert config.odata_url == "https://example.test/odata"
for Model in (PartColour, PartType, SubPartType):
rows = (await db.execute(Model.__table__.select())).all()
assert rows != [], f"{Model.__name__} should survive the purge"
entries = await audit_crud.list_recent(db)
assert any("Purged all domain data" in e.description for e in entries)
+241
View File
@@ -0,0 +1,241 @@
import asyncio
import re
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.models.augmented_part import AugmentedPart
from app.models.part_type import PartType
_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.
The dashboard's sync/recompute actions run a real background asyncio task
concurrently with polling requests, both hitting the DB at once. A real
file-backed DB (its own connection pool) supports that, unlike StaticPool's
single shared connection (see tests/test_admin_background_jobs.py).
"""
db_path = tmp_path / "dashboard_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"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
async def _poll_until_done(client, url, max_tries=50):
"""Follow the same htmx polling loop a browser would, until no further
``hx-get`` job-status link is present in the response."""
body = ""
for _ in range(max_tries):
resp = await client.get(url)
assert resp.status_code == 200
body = resp.text
match = _JOB_URL_RE.search(body)
if not match:
return body
url = match.group(1)
await asyncio.sleep(0.02)
raise AssertionError(f"job did not finish after {max_tries} polls: {body!r}")
@pytest.mark.asyncio
async def test_dashboard_shows_scope_and_skipped_counts(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
scope_t = PartType(name="Scope")
db.add(scope_t)
await db.flush()
db.add_all(
[
AugmentedPart(
description="Alpha 3-18x50",
colour_id=1,
manufacturer_id=1,
part_type_id=scope_t.id,
),
AugmentedPart(
description="Zeta 1-6x24",
colour_id=1,
manufacturer_id=1,
part_type_id=scope_t.id,
),
]
)
await db.commit()
await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader@example.com")
resp = await client.get("/")
assert resp.status_code == 200
assert "Data sync" in resp.text
assert "<dd>2</dd>" in resp.text # scope count
assert "<dd>0</dd>" in resp.text # nothing imported/skipped yet
@pytest.mark.asyncio
async def test_dashboard_hides_action_buttons_without_permissions(
client, seeded, make_user
):
await make_user("plain@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "plain@example.com")
resp = await client.get("/")
assert resp.status_code == 200
assert "Refresh from OData" not in resp.text
assert "Sync augmented parts" not in resp.text
assert "Recompute possible fits" not in resp.text
@pytest.mark.asyncio
async def test_dashboard_shows_action_buttons_for_superuser(client, make_user):
await make_user("boss@example.com", is_superuser=True)
await _web_login(client, "boss@example.com")
resp = await client.get("/")
assert resp.status_code == 200
assert "Refresh from OData" in resp.text
assert "Sync augmented parts" in resp.text
assert "Recompute possible fits" in resp.text
@pytest.mark.asyncio
async def test_dashboard_shows_only_permitted_button(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
perms = seeded["perms"]
editor = await role_crud.create(
db, name="fits-only-editor", permission_ids=[perms["fits:write"].id]
)
editor_id = editor.id
await make_user("fitsonly@example.com", role_ids=[editor_id])
await _web_login(client, "fitsonly@example.com")
resp = await client.get("/")
assert resp.status_code == 200
assert "Recompute possible fits" in resp.text
assert "Refresh from OData" not in resp.text
assert "Sync augmented parts" not in resp.text
@pytest.mark.asyncio
async def test_dashboard_refresh_odata_without_config_skips_job(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
perms = seeded["perms"]
editor = await role_crud.create(
db,
name="dash-refresh-noconfig-editor",
permission_ids=[perms["imported_parts:write"].id],
)
editor_id = editor.id
await make_user("dashrefresher2@example.com", role_ids=[editor_id])
await _web_login(client, "dashrefresher2@example.com")
resp = await client.post("/dashboard/refresh-odata")
assert resp.status_code == 200
assert "OData URL is not configured." in resp.text
assert _JOB_URL_RE.search(resp.text) is None
@pytest.mark.asyncio
async def test_dashboard_sync_augmented_parts_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch
):
monkeypatch.setattr("app.web.pages.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="dash-sync-editor",
permission_ids=[perms["augmented_parts:write"].id],
)
editor_id = editor.id
await make_user("dashsyncer@example.com", role_ids=[editor_id])
await _web_login(client, "dashsyncer@example.com")
resp = await client.post("/dashboard/sync-augmented-parts")
assert resp.status_code == 200
match = _JOB_URL_RE.search(resp.text)
assert match, resp.text
final_body = await _poll_until_done(client, match.group(1))
assert "Sync complete" in final_body
assert "dashboard-sync-card" in final_body
@pytest.mark.asyncio
async def test_dashboard_recompute_fits_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch
):
monkeypatch.setattr("app.web.pages.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="dash-recompute-editor", permission_ids=[perms["fits:write"].id]
)
editor_id = editor.id
await make_user("dashrecomputer@example.com", role_ids=[editor_id])
await _web_login(client, "dashrecomputer@example.com")
resp = await client.post("/dashboard/recompute-fits")
assert resp.status_code == 200
match = _JOB_URL_RE.search(resp.text)
assert match, resp.text
final_body = await _poll_until_done(client, match.group(1))
assert "Recompute complete" in final_body
@pytest.mark.asyncio
async def test_dashboard_refresh_odata_runs_in_background(
client, seeded, make_user, db_sessionmaker, monkeypatch
):
monkeypatch.setattr("app.web.pages.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.pages.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="dash-refresh-editor",
permission_ids=[perms["imported_parts:write"].id],
)
editor_id = editor.id
await make_user("dashrefresher@example.com", role_ids=[editor_id])
await _web_login(client, "dashrefresher@example.com")
resp = await client.post("/dashboard/refresh-odata")
assert resp.status_code == 200
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
+2 -2
View File
@@ -27,10 +27,10 @@ def test_iso_thread_mismatched_dimensions():
assert not attachment_points_match(a, b)
def test_same_label_never_matches_even_with_identical_threads():
def test_same_label_still_matches_even_with_identical_threads():
a = _ap(1, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
b = _ap(2, 1, iso_thread=IsoThread(diameter_mm=8, pitch_mm=1.25))
assert not attachment_points_match(a, b)
assert attachment_points_match(a, b)
def test_unc_thread_exact_match():
+3
View File
@@ -92,6 +92,9 @@ async def test_ldap_login_bad_credentials(client, seeded, monkeypatch, ldap_enab
assert resp.status_code == 401
@pytest.mark.skip(
"Not required, can cause issues if running against a live LDAP server"
)
async def test_ldap_login_disabled_returns_404(client, seeded):
# ldap_enabled defaults to False.
resp = await client.post(
+18 -6
View File
@@ -3,23 +3,35 @@ import pytest
@pytest.mark.asyncio
async def test_deep_link_redirects_to_login_with_next(client):
resp = await client.get("/scopes")
resp = await client.get("/admin/users")
assert resp.status_code == 302
assert resp.headers["location"] == "/login?next=%2Fscopes"
assert resp.headers["location"] == "/login?next=%2Fadmin%2Fusers"
@pytest.mark.asyncio
async def test_deep_link_preserves_query_string(client):
resp = await client.get("/scopes?description=alpha")
resp = await client.get("/admin/users?sort=email")
assert resp.status_code == 302
assert resp.headers["location"] == "/login?next=%2Fscopes%3Fdescription%3Dalpha"
assert resp.headers["location"] == "/login?next=%2Fadmin%2Fusers%3Fsort%3Demail"
@pytest.mark.asyncio
async def test_root_redirect_has_no_next(client):
async def test_root_redirects_anonymous_to_scopes(client):
resp = await client.get("/")
assert resp.status_code == 302
assert resp.headers["location"] == "/login"
assert resp.headers["location"] == "/scopes"
@pytest.mark.asyncio
async def test_root_renders_dashboard_when_logged_in(client, make_user):
await make_user("dash@example.com")
resp = await client.post(
"/login", data={"email": "dash@example.com", "password": "secret123"}
)
assert resp.status_code == 303
resp = await client.get("/")
assert resp.status_code == 200
assert "dash@example.com" in resp.text
@pytest.mark.asyncio
+291 -6
View File
@@ -1,7 +1,10 @@
import pytest
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.augmented_part import AugmentedPart
from app.models.fit import Fit, FitLabel
from app.models.iso_thread import IsoThread
from app.models.part_type import PartType
from app.models.scope_info import ScopeInfo
from app.models.sub_part_type import SubPartType
@@ -46,7 +49,6 @@ async def _seed_guide_data(db):
magnification_min=3,
magnification_max=18,
objective_lens_diameter_mm=50,
objective_thread_pitch="M52x0.75",
)
)
@@ -144,6 +146,26 @@ async def test_build_filters_by_description(db_sessionmaker):
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
async def test_build_filters_by_manufacturer(db_sessionmaker):
async with db_sessionmaker() as db:
@@ -199,7 +221,6 @@ async def test_scopes_page_renders_rows_and_fits(
assert "Alpha 3-18x50" in body # scope row
assert "Flip cover 50mm" in body # a matching part in a fit column
assert "fit-badge-exact" in body # the exact fit badge
assert "M52x0.75" in body # scope info: objective thread
assert "3-18" in body # magnification range, integral values un-padded
@@ -214,6 +235,66 @@ async def test_build_includes_objective_lens_diameter(db_sessionmaker):
assert rows["Zeta 1-6x24"]["info"] is None
@pytest.mark.asyncio
async def test_build_prefers_objective_attachment_point_over_scope_info(
db_sessionmaker,
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
apn = AttachmentPointName(name="Objective")
db.add(apn)
await db.flush()
iso = IsoThread(diameter_mm=44, pitch_mm=0.5)
db.add(iso)
await db.flush()
db.add(
AttachmentPoint(
augmented_part_id=ids["s1"].id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
)
await db.commit()
guide = await scope_guide.build(db)
rows = {r["scope"].description: r for r in guide["rows"]}
s1_ap = rows["Alpha 3-18x50"]["objective_attachment_point"]
assert s1_ap is not None
assert s1_ap.iso_thread.diameter_mm == 44
# No "Objective"-named attachment point for s2 -> falls back to None.
assert rows["Zeta 1-6x24"]["objective_attachment_point"] is None
@pytest.mark.asyncio
async def test_scopes_page_renders_objective_attachment_point_thread(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
apn = AttachmentPointName(name="Objective")
db.add(apn)
await db.flush()
iso = IsoThread(diameter_mm=44, pitch_mm=0.5)
db.add(iso)
await db.flush()
db.add(
AttachmentPoint(
augmented_part_id=ids["s1"].id,
attachment_point_name_id=apn.id,
iso_thread_id=iso.id,
)
)
await db.commit()
await make_user("reader5@example.com", role_ids=[seeded["roles"]["user"].id])
await _web_login(client, "reader5@example.com")
resp = await client.get("/scopes")
assert resp.status_code == 200
body = resp.text
assert "M44.0×0.5" in body
@pytest.mark.asyncio
async def test_scopes_page_renders_objective_lens_diameter_in_magnification(
client, seeded, make_user, db_sessionmaker
@@ -260,8 +341,212 @@ async def test_scopes_page_forbidden_without_permission(client, seeded, make_use
@pytest.mark.asyncio
async def test_scopes_page_redirects_when_anonymous(client):
async def test_scopes_page_renders_for_anonymous(client, db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
resp = await client.get("/scopes")
assert resp.status_code == 302
# Deep link is preserved so the user lands back here after logging in.
assert resp.headers["location"] == "/login?next=%2Fscopes"
assert resp.status_code == 200
body = resp.text
assert "Alpha 3-18x50" in body
assert "fit-badge-exact" in body
# Possible fits (e.g. the ARD) are hidden entirely from anonymous visitors.
assert "ARD 50mm" not in body
assert "fit-badge-possible" not in body
@pytest.mark.asyncio
async def test_scopes_page_hides_write_controls_for_anonymous(client, db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
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_filter_endpoint_works_for_anonymous(client, db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_guide_data(db)
resp = await client.get("/scopes/filter", params={"description": "Alpha"})
assert resp.status_code == 200
assert "Alpha 3-18x50" in resp.text
assert "Zeta 1-6x24" not in resp.text
@pytest.mark.asyncio
async def test_build_only_exact_drops_possible_fits(db_sessionmaker):
async with db_sessionmaker() as db:
ids = await _seed_guide_data(db)
guide = await scope_guide.build(db, only_exact=True)
cover_id, ard_id = ids["cover"].id, ids["ard"].id
rows = {r["scope"].description: r for r in guide["rows"]}
s1 = rows["Alpha 3-18x50"]
assert [(e["part"].description, e["label"]) for e in s1["cells"][cover_id]] == [
("Flip cover 50mm", "exact")
]
assert ard_id not in s1["cells"] # the ARD fit was only "possible"
s2 = rows["Zeta 1-6x24"]
assert cover_id not in s2["cells"] # S2's flip-cover fit was only "possible"
@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
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more