Compare commits

...
60 Commits
Author SHA1 Message Date
Ian Roddis e81c368663 Making adding a part manually less exhausting 2026-09-24 22:07:02 -03:00
Ian Roddis 8860c5691f Fixing skipped tables part assignment 2026-09-24 21:57:26 -03:00
Ian Roddis 054770d370 formatting 2026-09-21 14:16:17 -03:00
Ian Roddis 4fd2bb1b58 Adding on_website and preferred part indicators 2026-09-21 14:01:23 -03:00
Ian Roddis 13b64a2d17 Sync with imported parts now properly updates augmented parts and creates any new attachment points 2026-09-21 13:50:06 -03:00
Ian Roddis d23f5b9e82 Adding UAC Diameter Range MM to augmented parts 2026-09-21 13:38:13 -03:00
Ian Roddis 597cf68157 Adding UACDiameterRangemm 2026-09-21 13:31:30 -03:00
Ian Roddis d8db87c13e Adding ability to update / patch by sku, adding uniqueness constraint violation checks 2026-09-21 10:43:36 -03:00
Ian Roddis af5343da0c Adding TODO 2026-09-18 11:38:10 -03:00
Ian Roddis 90cae15551 Allowing manufacturer urls to be null 2026-09-04 23:07:35 -03:00
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
Ian Roddis 9ebd0f4498 Fixing automatic product_number assignment 2026-08-04 16:55:05 -03:00
Ian Roddis d54df47a2c Making objective_lens a thing 2026-08-04 16:48:12 -03:00
Ian Roddis 12e6984c63 Making objective_lens a thing 2026-08-04 16:48:00 -03:00
Ian Roddis fcb4dbaa62 Adding notes 2026-08-04 09:39:19 -03:00
Ian Roddis a617b935d3 Fixing formatting of magnification 2026-08-04 09:02:38 -03:00
Ian Roddis d108cb7149 Making long running operations asynchronous 2026-07-31 10:40:54 -03:00
Ian Roddis 373a2c6acf Adding favicon 2026-07-31 10:09:41 -03:00
Ian Roddis 2fb662080b Adding pervasive column sorting 2026-07-31 09:52:09 -03:00
Ian Roddis 34679d53e8 Adding AugmentedItem notes 2026-07-31 09:12:41 -03:00
Ian Roddis 30a13644ce Adding fit note at manual fit creation 2026-07-31 09:02:24 -03:00
Ian Roddis 23d07505bd Adding ability to autovivify an appropriate attachment point when manually entering a fit 2026-07-31 08:59:34 -03:00
Ian Roddis 92ca6b2a9f Adding fit type to create fit form 2026-07-31 08:56:39 -03:00
151 changed files with 6726 additions and 825 deletions

No files matched your search

+27 -1
View File
@@ -13,7 +13,33 @@
"Bash(docker build *)", "Bash(docker build *)",
"Bash(git status *)", "Bash(git status *)",
"Bash(git -C /home/iroddis/dev/armament/fitguide status --short)", "Bash(git -C /home/iroddis/dev/armament/fitguide status --short)",
"Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)" "Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)",
"Bash(node --check /Users/roddis/dev/armament/fitguide/app/static/js/sortable-tables.js)",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from users;\")",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from part_colours;\")",
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from manufacturers;\")",
"Bash(curl -s -c /tmp/fg_cookies.txt -o /dev/null -w \"login:%{http_code}\\\\n\" -d \"email=admin@armament.com&password=changethis\" http://localhost:8000/login)",
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/admin/manufacturers)",
"Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\\\\n\" http://localhost:8000/static/js/sortable-tables.js)",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts\")",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts/filter?sort=colour&dir=desc\")",
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/roles\")",
"Bash(curl -s http://localhost:8000/scopes)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/scopes)",
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/scopes)",
"Bash(lsof -ti:8000 -sTCP:LISTEN)",
"Bash(xargs -r kill)",
"Bash(rm -f /tmp/fg_cookies.txt /private/tmp/claude-501/-Users-roddis-dev-armament-fitguide/7873fd9c-2f2b-4583-877e-72fc88718f24/scratchpad/smoke_sort.py)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/login)",
"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 \"PRAGMA wal_checkpoint\\(FULL\\);\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)",
"Bash(rg -n \"hx-delete|router.delete\" app/web/admin.py app/templates/admin/*.html)",
"Bash(rg -n \"is_superuser\" tests/conftest.py tests/*.py)"
] ]
} }
} }
+3
View File
@@ -25,6 +25,9 @@ AUTO_CREATE_TABLES=true
COOKIE_NAME=access_token COOKIE_NAME=access_token
# Set true when serving over HTTPS. # Set true when serving over HTTPS.
COOKIE_SECURE=false COOKIE_SECURE=false
# How long a browser login lasts before being bounced back to /login.
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
SESSION_TIMEOUT_HOURS=24
# --- LDAP (optional; direct-bind) --- # --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory # When enabled, the login page and /api/auth/ldap-login accept a directory
+1 -1
View File
@@ -87,7 +87,7 @@ The business domain, independent of the auth/RBAC skeleton:
- **`app/services/scope_import.py`** is a separate, manually-triggered bulk - **`app/services/scope_import.py`** is a separate, manually-triggered bulk
importer: reads an uploaded `.xlsx` of third-party scopes, upserting by importer: reads an uploaded `.xlsx` of third-party scopes, upserting by
`product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`, `product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`,
`ScopeInfo`, up to three `AttachmentPoint`s, and up to two `Fit`s per row `ScopeInfo`, up to two `AttachmentPoint`s, and up to two `Fit`s per row
(see the module docstring for the full column mapping). (see the module docstring for the full column mapping).
- **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per - **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per
Scope-type `AugmentedPart`, one column per other `PartType`, each cell Scope-type `AugmentedPart`, one column per other `PartType`, each cell
+1
View File
@@ -21,3 +21,4 @@
- Models - Models
- Colours - Colours
- Part Types - Part Types
- Notes
+2 -2
View File
@@ -1,4 +1,4 @@
VERSION := $(shell git describe --tags --always) VERSION := $(shell git tag | tail -1)
IMAGE_NAME := fitguide IMAGE_NAME := fitguide
sync: sync:
@@ -8,7 +8,7 @@ seed:
uv run alembic upgrade head uv run alembic upgrade head
uv run python -m scripts.seed uv run python -m scripts.seed
dev: dev: migrate
uv run uvicorn app.main:app --reload uv run uvicorn app.main:app --reload
open http://localhost:8000 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** | | `SECRET_KEY` | dev placeholder | **set a strong value in production** |
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres | | `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | | | `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
+7
View File
@@ -0,0 +1,7 @@
[ ] Drop 'id' from UI tables, add 'date added / updated'
[x] Adapter Rings should get two attachment points
[ ] Add 'on_website' boolean to augmented part
[x] API: allow updating parts by ID or by SKU
[ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
[ ] Adapter Rings
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
@@ -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 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "0c3d1a5cff1f" revision: str = "0c3d1a5cff1f"
down_revision: Union[str, None] = "5b6a7817b408" down_revision: str | None = "5b6a7817b408"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "part_colours",
# ### end Alembic commands ### sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("part_colours")
pass
# ### end Alembic commands ###
@@ -0,0 +1,37 @@
"""manufacturers_url_nullable
Revision ID: 0fec09fb6ae6
Revises: 9a1c2f6e0b3d
Create Date: 2026-09-04 23:05:18.990806
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0fec09fb6ae6"
down_revision: str | None = "9a1c2f6e0b3d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
batch_op.alter_column(
"url",
existing_type=sa.String(),
nullable=True,
)
def downgrade() -> None:
op.execute("UPDATE manufacturers SET url = '' WHERE url IS NULL")
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
batch_op.alter_column(
"url",
existing_type=sa.String(),
nullable=False,
)
@@ -5,16 +5,17 @@ Revises: adf4a5b9493e
Create Date: 2026-07-14 15:00:00.000000 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "1b99be8a9b1e" revision: str = "1b99be8a9b1e"
down_revision: Union[str, None] = "adf4a5b9493e" down_revision: str | None = "adf4a5b9493e"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -31,10 +32,16 @@ def upgrade() -> None:
nullable=False, nullable=False,
), ),
sa.Column( sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False "created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
), ),
sa.Column( sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False "updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
), ),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE" ["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE"
@@ -59,7 +66,10 @@ def upgrade() -> None:
sa.Column("username", sa.String(length=255), nullable=False), sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False), sa.Column("body", sa.Text(), nullable=False),
sa.Column( sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False "created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
), ),
sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
@@ -0,0 +1,26 @@
"""augmented_parts_sku_unique
Revision ID: 1c2d3e4f5a6b
Revises: 0fec09fb6ae6
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1c2d3e4f5a6b"
down_revision: str | None = "0fec09fb6ae6"
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.create_index("ix_augmented_parts_sku", ["sku"], unique=True)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_index("ix_augmented_parts_sku")
@@ -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)
)
@@ -0,0 +1,28 @@
"""imported_parts_uac_diameter_range
Revision ID: 2b3c4d5e6f7a
Revises: 1c2d3e4f5a6b
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2b3c4d5e6f7a"
down_revision: str | None = "1c2d3e4f5a6b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
batch_op.add_column(sa.Column("UACDiameterRangemm", sa.String(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
batch_op.drop_column("UACDiameterRangemm")
@@ -0,0 +1,30 @@
"""augmented_parts_uac_diameter_range
Revision ID: 3c4d5e6f7a8b
Revises: 2b3c4d5e6f7a
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "3c4d5e6f7a8b"
down_revision: str | None = "2b3c4d5e6f7a"
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("uac_diameter_range_mm", sa.String(), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_column("uac_diameter_range_mm")
@@ -0,0 +1,41 @@
"""augmented_parts_on_website_preferred
Revision ID: 4d5e6f7a8b9c
Revises: 3c4d5e6f7a8b
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4d5e6f7a8b9c"
down_revision: str | None = "3c4d5e6f7a8b"
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(
"on_website", sa.Boolean(), nullable=False, server_default=sa.false()
)
)
batch_op.add_column(
sa.Column(
"preferred_part",
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("preferred_part")
batch_op.drop_column("on_website")
@@ -5,25 +5,29 @@ Revises: 7be5ef599d3f
Create Date: 2026-07-02 11:57:13.095417 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "5b6a7817b408" revision: str = "5b6a7817b408"
down_revision: Union[str, None] = "7be5ef599d3f" down_revision: str | None = "7be5ef599d3f"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "static_connections",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("static_connections")
pass
# ### end Alembic commands ###
@@ -6,16 +6,15 @@ Create Date: 2026-06-24 21:50:05.340235
""" """
from collections.abc import Sequence from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
revision: str = "627d4460a94f" revision: str = "627d4460a94f"
down_revision: Union[str, None] = "0001_initial" down_revision: str | None = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -5,25 +5,28 @@ Revises: c7882cedc9ae
Create Date: 2026-07-02 13:54:58.335394 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "70e48bda8829" revision: str = "70e48bda8829"
down_revision: Union[str, None] = "c7882cedc9ae" down_revision: str | None = "c7882cedc9ae"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "attachment_point_names",
# ### end Alembic commands ### sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("attachment_point_names")
pass
# ### end Alembic commands ###
@@ -5,25 +5,42 @@ Revises: b60d0aa3ceda
Create Date: 2026-06-25 15:29:20.685050 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "7be5ef599d3f" revision: str = "7be5ef599d3f"
down_revision: Union[str, None] = "b60d0aa3ceda" down_revision: str | None = "b60d0aa3ceda"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "imported_parts",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("imported_part_config")
pass op.drop_table("imported_parts")
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""add_augmented_part_notes_table
Revision ID: 8012c00b0c04
Revises: d1e2f3a4b5c6
Create Date: 2026-07-31 09:07:06.137175
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8012c00b0c04"
down_revision: str | None = "d1e2f3a4b5c6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"augmented_part_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("augmented_part_notes")
@@ -5,25 +5,48 @@ Revises: 0c3d1a5cff1f
Create Date: 2026-07-02 12:37:59.457033 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "8bc411b5814e" revision: str = "8bc411b5814e"
down_revision: Union[str, None] = "0c3d1a5cff1f" down_revision: str | None = "0c3d1a5cff1f"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "augmented_parts",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("augmented_parts")
pass
# ### end Alembic commands ###
@@ -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 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "a192abbf4091" revision: str = "a192abbf4091"
down_revision: Union[str, None] = "627d4460a94f" down_revision: str | None = "627d4460a94f"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -27,12 +28,19 @@ def upgrade() -> None:
sa.Column("logo_url", sa.String(), nullable=True), sa.Column("logo_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"), 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: with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column( batch_op.alter_column(
"series", "series",
existing_type=sa.VARCHAR(length=8), existing_type=sa.VARCHAR(length=8),
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"), type_=uncseries,
existing_nullable=False, existing_nullable=False,
postgresql_using="series::uncseries",
) )
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -46,7 +54,9 @@ def downgrade() -> None:
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"), existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
type_=sa.VARCHAR(length=8), type_=sa.VARCHAR(length=8),
existing_nullable=False, existing_nullable=False,
postgresql_using="series::varchar(8)",
) )
sa.Enum(name="uncseries").drop(op.get_bind(), checkfirst=True)
op.drop_table("manufacturers") op.drop_table("manufacturers")
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add_objective_lens_diameter_to_scope_infos
Revision ID: a3f7c19b2d40
Revises: 8012c00b0c04
Create Date: 2026-08-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a3f7c19b2d40"
down_revision: str | None = "8012c00b0c04"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"scope_infos",
sa.Column("objective_lens_diameter_mm", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_column("scope_infos", "objective_lens_diameter_mm")
@@ -5,16 +5,17 @@ Revises: a192abbf4091
Create Date: 2026-06-25 10:35:40.227253 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "a52e7ca7a925" revision: str = "a52e7ca7a925"
down_revision: Union[str, None] = "a192abbf4091" down_revision: str | None = "a192abbf4091"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -5,25 +5,52 @@ Revises: 70e48bda8829
Create Date: 2026-07-02 14:03:23.561253 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "adf4a5b9493e" revision: str = "adf4a5b9493e"
down_revision: Union[str, None] = "70e48bda8829" down_revision: str | None = "70e48bda8829"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "attachment_points",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("attachment_points")
pass
# ### end Alembic commands ###
@@ -5,25 +5,32 @@ Revises: fd5379986da4
Create Date: 2026-06-25 11:29:20.420056 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "b60d0aa3ceda" revision: str = "b60d0aa3ceda"
down_revision: Union[str, None] = "fd5379986da4" down_revision: str | None = "fd5379986da4"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "sub_part_types",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("sub_part_types")
pass
# ### end Alembic commands ###
@@ -5,25 +5,33 @@ Revises: 8bc411b5814e
Create Date: 2026-07-02 12:46:27.493942 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "bfbce324cd5f" revision: str = "bfbce324cd5f"
down_revision: Union[str, None] = "8bc411b5814e" down_revision: str | None = "8bc411b5814e"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "augmented_part_imported_parts",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("augmented_part_imported_parts")
pass
# ### end Alembic commands ###
@@ -5,25 +5,30 @@ Revises: bfbce324cd5f
Create Date: 2026-07-02 13:31:57.064381 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "c7882cedc9ae" revision: str = "c7882cedc9ae"
down_revision: Union[str, None] = "bfbce324cd5f" down_revision: str | None = "bfbce324cd5f"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "clamp_connections",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_table("clamp_connections")
pass
# ### end Alembic commands ###
@@ -5,16 +5,17 @@ Revises: c9f1a2b3d4e5
Create Date: 2026-07-16 20:00:00.000000 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "d1e2f3a4b5c6" revision: str = "d1e2f3a4b5c6"
down_revision: Union[str, None] = "c9f1a2b3d4e5" down_revision: str | None = "c9f1a2b3d4e5"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -5,16 +5,17 @@ Revises: a52e7ca7a925
Create Date: 2026-06-25 10:49:56.463466 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "e23aa8dd2536" revision: str = "e23aa8dd2536"
down_revision: Union[str, None] = "a52e7ca7a925" down_revision: str | None = "a52e7ca7a925"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
@@ -5,25 +5,43 @@ Revises: e23aa8dd2536
Create Date: 2026-06-25 11:06:13.028067 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 from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "fd5379986da4" revision: str = "fd5379986da4"
down_revision: Union[str, None] = "e23aa8dd2536" down_revision: str | None = "e23aa8dd2536"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: str | Sequence[str] | None = None
depends_on: Union[str, Sequence[str], None] = None depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.create_table(
pass "audit_logs",
# ### end Alembic commands ### 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: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
pass op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
# ### end Alembic commands ### op.drop_table("audit_logs")
+10
View File
@@ -33,6 +33,10 @@ async def create_attachment_point_name(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")), actor: User = Depends(require_permission("attachment_point_names:write")),
): ):
if await apn_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=409, detail="Attachment point name already exists"
)
apn = await apn_crud.create(db, name=payload.name) apn = await apn_crud.create(db, name=payload.name)
await audit_crud.log( await audit_crud.log(
db, db,
@@ -68,6 +72,12 @@ async def update_attachment_point_name(
if apn is None: if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found") raise HTTPException(status_code=404, detail="Attachment point name not found")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await apn_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != apn.id:
raise HTTPException(
status_code=409, detail="Attachment point name already exists"
)
apn = await apn_crud.update(db, apn, changes) apn = await apn_crud.update(db, apn, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+7 -3
View File
@@ -27,7 +27,7 @@ def _connection_label(ap) -> str:
return f"{cc.min_in}–{cc.max_in} in" return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection: if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD" return f"{ap.static_connection.od_mm} mm OD"
return "—" return "Manual"
@router.get("", response_model=list[AttachmentPointRead]) @router.get("", response_model=list[AttachmentPointRead])
@@ -54,10 +54,14 @@ async def create_attachment_point(
payload.clamp_connection_id, payload.clamp_connection_id,
payload.static_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( raise HTTPException(
status_code=400, 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()) ap = await attachment_point_crud.create(db, **payload.model_dump())
await audit_crud.log( await audit_crud.log(
+54 -13
View File
@@ -4,10 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
from app.crud import augmented_part_note as augmented_part_note_crud
from app.deps import require_permission from app.deps import require_permission
from app.models.user import User from app.models.user import User
from app.schemas.augmented_part import ( from app.schemas.augmented_part import (
AugmentedPartCreate, AugmentedPartCreate,
AugmentedPartNoteCreate,
AugmentedPartNoteRead,
AugmentedPartRead, AugmentedPartRead,
AugmentedPartSyncResult, AugmentedPartSyncResult,
AugmentedPartUpdate, AugmentedPartUpdate,
@@ -17,14 +20,26 @@ from app.services import augmented_part_sync
router = APIRouter() router = APIRouter()
async def _get_augmented_part_by_identifier(db: AsyncSession, identifier: str):
"""Resolve an AugmentedPart by numeric id, falling back to sku."""
if identifier.isdigit():
ap = await augmented_part_crud.get(db, int(identifier))
if ap is not None:
return ap
return await augmented_part_crud.get_by_sku(db, identifier)
@router.get("", response_model=list[AugmentedPartRead]) @router.get("", response_model=list[AugmentedPartRead])
async def list_augmented_parts( async def list_augmented_parts(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
include_archived: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")), _: 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) @router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
@@ -33,6 +48,8 @@ async def create_augmented_part(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")), actor: User = Depends(require_permission("augmented_parts:write")),
): ):
if payload.sku and await augmented_part_crud.get_by_sku(db, payload.sku):
raise HTTPException(status_code=409, detail="SKU already in use")
ap = await augmented_part_crud.create( ap = await augmented_part_crud.create(
db, db,
sku=payload.sku, sku=payload.sku,
@@ -42,6 +59,8 @@ async def create_augmented_part(
scope_model_id=payload.scope_model_id, scope_model_id=payload.scope_model_id,
part_type_id=payload.part_type_id, part_type_id=payload.part_type_id,
sub_part_type_id=payload.sub_part_type_id, sub_part_type_id=payload.sub_part_type_id,
on_website=payload.on_website,
preferred_part=payload.preferred_part,
) )
await audit_crud.log( await audit_crud.log(
db, db,
@@ -69,8 +88,8 @@ async def sync_augmented_parts(
resource_type="AugmentedPart", resource_type="AugmentedPart",
resource_id=None, resource_id=None,
description=( description=(
f"Sync: {result.created} created, {result.deleted} deleted, " f"Sync: {result.created} created, {result.updated} updated, "
f"{result.skipped} skipped (no type match)" f"{result.archived} archived, {result.skipped} skipped (no type match)"
), ),
) )
return result return result
@@ -88,17 +107,21 @@ async def get_augmented_part(
return ap return ap
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead) @router.patch("/{identifier}", response_model=AugmentedPartRead)
async def update_augmented_part( async def update_augmented_part(
augmented_part_id: int, identifier: str,
payload: AugmentedPartUpdate, payload: AugmentedPartUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")), actor: User = Depends(require_permission("augmented_parts:write")),
): ):
ap = await augmented_part_crud.get(db, augmented_part_id) ap = await _get_augmented_part_by_identifier(db, identifier)
if ap is None: if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found") raise HTTPException(status_code=404, detail="Augmented part not found")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if changes.get("sku"):
existing = await augmented_part_crud.get_by_sku(db, changes["sku"])
if existing is not None and existing.id != ap.id:
raise HTTPException(status_code=409, detail="SKU already in use")
ap = await augmented_part_crud.update(db, ap, changes) ap = await augmented_part_crud.update(db, ap, changes)
await audit_crud.log( await audit_crud.log(
db, db,
@@ -111,15 +134,16 @@ async def update_augmented_part(
return ap return ap
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_augmented_part( async def delete_augmented_part(
augmented_part_id: int, identifier: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:delete")), actor: User = Depends(require_permission("augmented_parts:delete")),
): ):
ap = await augmented_part_crud.get(db, augmented_part_id) ap = await _get_augmented_part_by_identifier(db, identifier)
if ap is None: if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found") raise HTTPException(status_code=404, detail="Augmented part not found")
resolved_id = ap.id
label = ap.sku or ap.description label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap) await augmented_part_crud.delete(db, ap)
await audit_crud.log( await audit_crud.log(
@@ -127,8 +151,25 @@ async def delete_augmented_part(
actor=actor, actor=actor,
action="delete", action="delete",
resource_type="AugmentedPart", resource_type="AugmentedPart",
resource_id=augmented_part_id, resource_id=resolved_id,
description=audit_crud.describe_delete( description=audit_crud.describe_delete("AugmentedPart", resolved_id, label),
"AugmentedPart", augmented_part_id, label )
),
@router.post(
"/{augmented_part_id}/notes",
response_model=AugmentedPartNoteRead,
status_code=status.HTTP_201_CREATED,
)
async def create_augmented_part_note(
augmented_part_id: int,
payload: AugmentedPartNoteCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
return await augmented_part_note_crud.create(
db, augmented_part=ap, author=actor, body=payload.body
) )
+1 -3
View File
@@ -67,9 +67,7 @@ async def create_fit(
if ap_a is None or ap_b is None: if ap_a is None or ap_b is None:
raise HTTPException(status_code=404, detail="Attachment point not found") raise HTTPException(status_code=404, detail="Attachment point not found")
if ap_a.augmented_part_id == ap_b.augmented_part_id: if ap_a.augmented_part_id == ap_b.augmented_part_id:
raise HTTPException( raise HTTPException(status_code=422, detail="a part cannot fit itself")
status_code=422, detail="a part cannot fit itself"
)
existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b) existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b)
if existing is not None: if existing is not None:
raise HTTPException(status_code=409, detail="Fit already exists") raise HTTPException(status_code=409, detail="Fit already exists")
+16 -1
View File
@@ -13,6 +13,7 @@ from app.schemas.imported_part import (
ImportedPartRead, ImportedPartRead,
SyncResult, SyncResult,
) )
from app.services import augmented_part_sync
from app.services import odata as odata_service from app.services import odata as odata_service
router = APIRouter() router = APIRouter()
@@ -128,7 +129,9 @@ async def delete_imported_part(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found" 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( await audit_crud.log(
db, db,
actor=actor, actor=actor,
@@ -137,3 +140,15 @@ async def delete_imported_part(
resource_id=None, resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id), 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}"
),
)
+6
View File
@@ -27,6 +27,8 @@ async def create_part_colour(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")), actor: User = Depends(require_permission("part_colours:write")),
): ):
if await part_colour_crud.get_by_name(db, payload.name):
raise HTTPException(status_code=409, detail="Part colour already exists")
pc = await part_colour_crud.create(db, name=payload.name) pc = await part_colour_crud.create(db, name=payload.name)
await audit_crud.log( await audit_crud.log(
db, db,
@@ -62,6 +64,10 @@ async def update_part_colour(
if pc is None: if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found") raise HTTPException(status_code=404, detail="Part colour not found")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await part_colour_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != pc.id:
raise HTTPException(status_code=409, detail="Part colour already exists")
pc = await part_colour_crud.update(db, pc, changes) pc = await part_colour_crud.update(db, pc, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+10
View File
@@ -27,6 +27,10 @@ async def create_part_type(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")), actor: User = Depends(require_permission("part_types:write")),
): ):
if await part_type_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
)
pt = await part_type_crud.create(db, name=payload.name) pt = await part_type_crud.create(db, name=payload.name)
await audit_crud.log( await audit_crud.log(
db, db,
@@ -66,6 +70,12 @@ async def update_part_type(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found" status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
) )
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await part_type_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != pt.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
)
pt = await part_type_crud.update(db, pt, changes) pt = await part_type_crud.update(db, pt, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+6
View File
@@ -81,6 +81,12 @@ async def update_role(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
) )
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await role_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != role.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
role = await role_crud.update(db, role, changes) role = await role_crud.update(db, role, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+8
View File
@@ -33,6 +33,8 @@ async def create_static_connection(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")), actor: User = Depends(require_permission("static_connections:write")),
): ):
if await static_connection_crud.get_by_od_mm(db, payload.od_mm):
raise HTTPException(status_code=409, detail="Static connection already exists")
sc = await static_connection_crud.create( sc = await static_connection_crud.create(
db, od_mm=payload.od_mm, od_in=payload.od_in db, od_mm=payload.od_mm, od_in=payload.od_in
) )
@@ -72,6 +74,12 @@ async def update_static_connection(
if sc is None: if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found") raise HTTPException(status_code=404, detail="Static connection not found")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "od_mm" in changes:
existing = await static_connection_crud.get_by_od_mm(db, changes["od_mm"])
if existing is not None and existing.id != sc.id:
raise HTTPException(
status_code=409, detail="Static connection already exists"
)
sc = await static_connection_crud.update(db, sc, changes) sc = await static_connection_crud.update(db, sc, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+12
View File
@@ -31,6 +31,10 @@ async def create_sub_part_type(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")), actor: User = Depends(require_permission("sub_part_types:write")),
): ):
if await sub_part_type_crud.get_by_name_and_part_type(
db, payload.name, payload.part_type_id
):
raise HTTPException(status_code=409, detail="Sub part type already exists")
spt = await sub_part_type_crud.create( spt = await sub_part_type_crud.create(
db, name=payload.name, part_type_id=payload.part_type_id db, name=payload.name, part_type_id=payload.part_type_id
) )
@@ -72,6 +76,14 @@ async def update_sub_part_type(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found" status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
) )
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "name" in changes or "part_type_id" in changes:
name = changes.get("name", spt.name)
part_type_id = changes.get("part_type_id", spt.part_type_id)
existing = await sub_part_type_crud.get_by_name_and_part_type(
db, name, part_type_id
)
if existing is not None and existing.id != spt.id:
raise HTTPException(status_code=409, detail="Sub part type already exists")
spt = await sub_part_type_crud.update(db, spt, changes) spt = await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+6
View File
@@ -82,6 +82,12 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) )
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if "email" in changes:
existing = await user_crud.get_by_email(db, changes["email"])
if existing is not None and existing.id != target.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
target = await user_crud.update(db, target, changes) target = await user_crud.update(db, target, changes)
await audit_crud.log( await audit_crud.log(
db, db,
+5 -1
View File
@@ -33,9 +33,13 @@ class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./app.db" database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie) # Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
# session_timeout_hours governs how long a browser login lasts before the
# user is bounced back to /login; it's independent of the JSON API's
# short-lived access_token_expire_minutes.
cookie_name: str = "access_token" cookie_name: str = "access_token"
cookie_secure: bool = False cookie_secure: bool = False
session_timeout_hours: int = 24
# LDAP (direct-bind). When enabled, the login page offers an LDAP option. # LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with # At login the user's DN is built from ``ldap_bind_dn_template`` (with
+1
View File
@@ -35,6 +35,7 @@ if engine.sync_engine.dialect.name == "sqlite":
cursor.execute("PRAGMA busy_timeout=5000") cursor.execute("PRAGMA busy_timeout=5000")
cursor.close() cursor.close()
AsyncSessionLocal = async_sessionmaker( AsyncSessionLocal = async_sessionmaker(
engine, engine,
class_=AsyncSession, class_=AsyncSession,
+4 -2
View File
@@ -47,10 +47,12 @@ def _create_token(
return token, payload return token, payload
def create_access_token(subject: str | int) -> str: def create_access_token(
subject: str | int, expires_delta: timedelta | None = None
) -> str:
token, _ = _create_token( token, _ = _create_token(
subject, subject,
timedelta(minutes=settings.access_token_expire_minutes), expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TYPE,
) )
return token return token
+19
View File
@@ -2,6 +2,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
_RELATIONSHIPS = [ _RELATIONSHIPS = [
"attachment_point_name", "attachment_point_name",
@@ -27,6 +28,24 @@ async def list_for_augmented_part(
return list(result.scalars().all()) return list(result.scalars().all())
async def list_by_augmented_parts_and_name(
db: AsyncSession, augmented_part_ids: list[int], name: str
) -> list[AttachmentPoint]:
"""AttachmentPoints named ``name`` for the given augmented parts, fetched in
one query."""
if not augmented_part_ids:
return []
result = await db.execute(
select(AttachmentPoint)
.join(AttachmentPoint.attachment_point_name)
.where(
AttachmentPoint.augmented_part_id.in_(augmented_part_ids),
AttachmentPointName.name == name,
)
)
return list(result.scalars().all())
async def list_all( async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100 db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPoint]: ) -> list[AttachmentPoint]:
+93 -17
View File
@@ -1,11 +1,56 @@
from sqlalchemy import distinct, select from sqlalchemy import distinct, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart 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.models.scope_model import ScopeModel
from app.models.sub_part_type import SubPartType
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"] _RELATIONSHIPS = [
"colour",
"manufacturer",
"scope_model",
"part_type",
"sub_part_type",
"notes",
]
# Sortable columns for the admin list, keyed by the query-string "sort" value.
# Columns backed by a related table's name require an outer join (the FK may be
# nullable, e.g. scope_model_id) so unrelated rows still appear, ordered last.
SORT_COLUMNS = {
"id": (AugmentedPart.id, None),
"sku": (AugmentedPart.sku, None),
"description": (AugmentedPart.description, None),
"colour": (PartColour.name, (PartColour, AugmentedPart.colour_id == PartColour.id)),
"manufacturer": (
Manufacturer.name,
(Manufacturer, AugmentedPart.manufacturer_id == Manufacturer.id),
),
"scope_model": (
ScopeModel.name,
(ScopeModel, AugmentedPart.scope_model_id == ScopeModel.id),
),
"part_type": (PartType.name, (PartType, AugmentedPart.part_type_id == PartType.id)),
"sub_part_type": (
SubPartType.name,
(SubPartType, AugmentedPart.sub_part_type_id == SubPartType.id),
),
}
def _apply_sort(q, sort: str, direction: str):
column, join = SORT_COLUMNS.get(sort, SORT_COLUMNS["description"])
if join is not None:
table, cond = join
q = q.outerjoin(table, cond)
order = column.desc() if direction == "desc" else column.asc()
# AugmentedPart.id as a tiebreaker keeps paginated ordering stable.
return q.order_by(order, AugmentedPart.id)
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None: async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
@@ -20,14 +65,15 @@ async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
async def list_all( 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]: ) -> list[AugmentedPart]:
result = await db.execute( q = select(AugmentedPart).order_by(AugmentedPart.description)
select(AugmentedPart) if not include_archived:
.order_by(AugmentedPart.description) q = q.where(AugmentedPart.archived.is_(False))
.offset(skip) result = await db.execute(q.offset(skip).limit(limit))
.limit(limit)
)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -35,9 +81,7 @@ async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]:
"""Fetch augmented parts by id in one query (selectin relationships loaded).""" """Fetch augmented parts by id in one query (selectin relationships loaded)."""
if not ids: if not ids:
return [] return []
result = await db.execute( result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids)))
select(AugmentedPart).where(AugmentedPart.id.in_(ids))
)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -49,12 +93,19 @@ async def list_filtered(
scope_model_id: int | None = None, scope_model_id: int | None = None,
part_type_id: int | None = None, part_type_id: int | None = None,
sub_part_type_id: int | None = None, sub_part_type_id: int | None = None,
include_archived: bool = False,
on_website: bool = False,
sort: str = "description",
direction: str = "asc",
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> list[AugmentedPart]: ) -> list[AugmentedPart]:
q = select(AugmentedPart) q = select(AugmentedPart)
if description.strip(): if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%")) term = f"%{description.strip()}%"
q = q.where(
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
)
if colour_id is not None: if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id) q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None: if manufacturer_id is not None:
@@ -65,9 +116,12 @@ async def list_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id) q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None: if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id) q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
result = await db.execute( if not include_archived:
q.order_by(AugmentedPart.description).offset(skip).limit(limit) q = q.where(AugmentedPart.archived.is_(False))
) if on_website:
q = q.where(AugmentedPart.on_website.is_(True))
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -79,12 +133,17 @@ async def count_filtered(
scope_model_id: int | None = None, scope_model_id: int | None = None,
part_type_id: int | None = None, part_type_id: int | None = None,
sub_part_type_id: int | None = None, sub_part_type_id: int | None = None,
include_archived: bool = False,
on_website: bool = False,
) -> int: ) -> int:
from sqlalchemy import func from sqlalchemy import func
q = select(func.count()).select_from(AugmentedPart) q = select(func.count()).select_from(AugmentedPart)
if description.strip(): 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: if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id) q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None: if manufacturer_id is not None:
@@ -95,6 +154,10 @@ async def count_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id) q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None: if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id) q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
if on_website:
q = q.where(AugmentedPart.on_website.is_(True))
result = await db.execute(q) result = await db.execute(q)
return result.scalar_one() return result.scalar_one()
@@ -170,6 +233,8 @@ async def create(
scope_model_id: int | None, scope_model_id: int | None,
part_type_id: int, part_type_id: int,
sub_part_type_id: int | None, sub_part_type_id: int | None,
on_website: bool = False,
preferred_part: bool = False,
) -> AugmentedPart: ) -> AugmentedPart:
ap = AugmentedPart( ap = AugmentedPart(
sku=sku, sku=sku,
@@ -179,6 +244,8 @@ async def create(
scope_model_id=scope_model_id, scope_model_id=scope_model_id,
part_type_id=part_type_id, part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id, sub_part_type_id=sub_part_type_id,
on_website=on_website,
preferred_part=preferred_part,
) )
db.add(ap) db.add(ap)
await db.commit() await db.commit()
@@ -208,6 +275,15 @@ async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
return list(result.scalars().all()) 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: async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
await db.delete(ap) await db.delete(ap)
await db.commit() await db.commit()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.user import User
async def create(
db: AsyncSession, *, augmented_part: AugmentedPart, author: User, body: str
) -> AugmentedPartNote:
note = AugmentedPartNote(
augmented_part_id=augmented_part.id,
user_id=author.id,
username=author.full_name or author.email,
body=body,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
+11
View File
@@ -22,6 +22,17 @@ async def list_all(
return list(result.scalars().all()) return list(result.scalars().all())
async def get_by_values(
db: AsyncSession, *, min_mm: float | None, max_mm: float | None
) -> ClampConnection | None:
result = await db.execute(
select(ClampConnection).where(
ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm
)
)
return result.scalars().first()
async def create( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
+57 -14
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 sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
@@ -13,17 +13,42 @@ _FIELDS = frozenset(
"ProductODmm", "ProductODmm",
"ProductODin", "ProductODin",
"TBXThreadAttribute", "TBXThreadAttribute",
"UACDiameterRangemm",
} }
) )
# Sortable columns for the admin list, keyed by the query-string "sort" value.
SORT_COLUMNS = {
"InventoryID": ImportedPart.InventoryID,
"Description": ImportedPart.Description,
"TBXColour": ImportedPart.TBXColour,
"ProductMaxThreadDepthmm": ImportedPart.ProductMaxThreadDepthmm,
"ProductMaxThreadDepthin": ImportedPart.ProductMaxThreadDepthin,
"ProductODmm": ImportedPart.ProductODmm,
"ProductODin": ImportedPart.ProductODin,
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
"UACDiameterRangemm": ImportedPart.UACDiameterRangemm,
}
def _apply_sort(q, sort: str, direction: str):
column = SORT_COLUMNS.get(sort, ImportedPart.InventoryID)
order = column.desc() if direction == "desc" else column.asc()
return q.order_by(order, ImportedPart.InventoryID)
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None: async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id) return await db.get(ImportedPart, inventory_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]: async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 500
) -> list[ImportedPart]:
result = await db.execute( result = await db.execute(
select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit) select(ImportedPart)
.order_by(ImportedPart.InventoryID)
.offset(skip)
.limit(limit)
) )
return list(result.scalars().all()) return list(result.scalars().all())
@@ -31,13 +56,22 @@ async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[Im
async def list_filtered( async def list_filtered(
db: AsyncSession, db: AsyncSession,
description: str = "", description: str = "",
sort: str = "InventoryID",
direction: str = "asc",
skip: int = 0, skip: int = 0,
limit: int = 500, limit: int = 500,
) -> list[ImportedPart]: ) -> list[ImportedPart]:
q = select(ImportedPart) q = select(ImportedPart)
if description.strip(): if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%")) term = f"%{description.strip()}%"
result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)) 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()) return list(result.scalars().all())
@@ -62,23 +96,28 @@ async def count_filtered(db: AsyncSession, description: str = "") -> int:
q = select(func.count()).select_from(ImportedPart) q = select(func.count()).select_from(ImportedPart)
if description.strip(): 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) result = await db.execute(q)
return result.scalar_one() 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]: async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated).""" """Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records: if not records:
return 0, 0 return 0, 0
clean = [ clean = [
{k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS} {
k: str(v).strip() if v is not None else None
for k, v in r.items()
if k in _FIELDS
}
for r in records for r in records
if r.get("InventoryID") if r.get("InventoryID")
] ]
@@ -88,7 +127,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
incoming_ids = {r["InventoryID"] for r in clean} incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute( result = await db.execute(
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids)) select(ImportedPart.InventoryID).where(
ImportedPart.InventoryID.in_(incoming_ids)
)
) )
existing_ids = {row[0] for row in result.all()} existing_ids = {row[0] for row in result.all()}
@@ -103,7 +144,9 @@ async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
vals = {k: v for k, v in record.items() if k != "InventoryID"} vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals: if vals:
await db.execute( await db.execute(
update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals) update(ImportedPart)
.where(ImportedPart.InventoryID == inv_id)
.values(vals)
) )
await db.commit() await db.commit()
+1 -1
View File
@@ -26,7 +26,7 @@ async def create(
db: AsyncSession, db: AsyncSession,
*, *,
name: str, name: str,
url: str, url: str | None = None,
logo_url: str | None = None, logo_url: str | None = None,
) -> Manufacturer: ) -> Manufacturer:
m = Manufacturer(name=name, url=url, logo_url=logo_url) m = Manufacturer(name=name, url=url, logo_url=logo_url)
+1 -3
View File
@@ -23,9 +23,7 @@ async def get_default(db: AsyncSession) -> Role | None:
async def set_default(db: AsyncSession, role: Role) -> Role: async def set_default(db: AsyncSession, role: Role) -> Role:
"""Make ``role`` the sole default, clearing the flag on every other role.""" """Make ``role`` the sole default, clearing the flag on every other role."""
await db.execute( await db.execute(sa_update(Role).where(Role.id != role.id).values(is_default=False))
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True role.is_default = True
await db.commit() await db.commit()
await db.refresh(role) await db.refresh(role)
+3 -5
View File
@@ -24,9 +24,7 @@ async def list_by_augmented_parts(
if not augmented_part_ids: if not augmented_part_ids:
return [] return []
result = await db.execute( result = await db.execute(
select(ScopeInfo).where( select(ScopeInfo).where(ScopeInfo.augmented_part_id.in_(augmented_part_ids))
ScopeInfo.augmented_part_id.in_(augmented_part_ids)
)
) )
return list(result.scalars().all()) return list(result.scalars().all())
@@ -38,14 +36,14 @@ async def create(
magnification_min: float | None = None, magnification_min: float | None = None,
magnification_max: float | None = None, magnification_max: float | None = None,
model_suffix: str | None = None, model_suffix: str | None = None,
objective_thread_pitch: str | None = None, objective_lens_diameter_mm: float | None = None,
) -> ScopeInfo: ) -> ScopeInfo:
info = ScopeInfo( info = ScopeInfo(
augmented_part_id=augmented_part_id, augmented_part_id=augmented_part_id,
magnification_min=magnification_min, magnification_min=magnification_min,
magnification_max=magnification_max, magnification_max=magnification_max,
model_suffix=model_suffix, model_suffix=model_suffix,
objective_thread_pitch=objective_thread_pitch, objective_lens_diameter_mm=objective_lens_diameter_mm,
) )
db.add(info) db.add(info)
await db.commit() 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 # Preserve the originally requested URL so we can return there after
# login. Only meaningful for GET navigations (the ones users deep-link). # login. Only meaningful for GET navigations (the ones users deep-link).
login_url = "/login" 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 target = request.url.path
if request.url.query: if request.url.query:
target += "?" + request.url.query target += "?" + request.url.query
+6 -3
View File
@@ -8,22 +8,23 @@ from app.models.associations import (
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread from app.models.iso_thread import IsoThread
from app.models.job import Job
from app.models.manufacturer import Manufacturer from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour from app.models.part_colour import PartColour
from app.models.part_type import PartType from app.models.part_type import PartType
from app.models.permission import Permission 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_info import ScopeInfo
from app.models.scope_model import ScopeModel from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType from app.models.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.unc_thread import UncThread
from app.models.user import User from app.models.user import User
@@ -32,6 +33,7 @@ __all__ = [
"AttachmentPointName", "AttachmentPointName",
"AuditLog", "AuditLog",
"AugmentedPart", "AugmentedPart",
"AugmentedPartNote",
"ClampConnection", "ClampConnection",
"Fit", "Fit",
"FitLabel", "FitLabel",
@@ -39,6 +41,7 @@ __all__ = [
"ImportedPart", "ImportedPart",
"ImportedPartConfig", "ImportedPartConfig",
"IsoThread", "IsoThread",
"Job",
"Manufacturer", "Manufacturer",
"PartColour", "PartColour",
"PartType", "PartType",
+4 -1
View File
@@ -21,7 +21,10 @@ class AttachmentPoint(Base):
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False 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( iso_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
) )
+44 -2
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, String from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -15,7 +17,9 @@ class AugmentedPart(Base):
__tablename__ = "augmented_parts" __tablename__ = "augmented_parts"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None) sku: Mapped[str | None] = mapped_column(
String, nullable=True, unique=True, default=None
)
description: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[str] = mapped_column(String, nullable=False)
colour_id: Mapped[int] = mapped_column( colour_id: Mapped[int] = mapped_column(
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
@@ -34,6 +38,17 @@ class AugmentedPart(Base):
nullable=True, nullable=True,
default=None, default=None,
) )
# "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync
# service. Read-only — never set outside app/services/augmented_part_sync.py.
uac_diameter_range_mm: Mapped[str | None] = mapped_column(
String, 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)
on_website: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
preferred_part: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# linked imported parts — not eagerly loaded; used by sync service only # linked imported parts — not eagerly loaded; used by sync service only
imported_parts: Mapped[list[ImportedPart]] = relationship( imported_parts: Mapped[list[ImportedPart]] = relationship(
@@ -45,3 +60,30 @@ class AugmentedPart(Base):
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
part_type: Mapped[PartType] = relationship(lazy="selectin") part_type: Mapped[PartType] = relationship(lazy="selectin")
sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin") sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin")
notes: Mapped[list["AugmentedPartNote"]] = relationship(
back_populates="augmented_part",
lazy="selectin",
order_by="AugmentedPartNote.created_at",
cascade="all, delete-orphan",
)
class AugmentedPartNote(Base):
__tablename__ = "augmented_part_notes"
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Denormalized snapshot of who wrote the note (mirrors FitNote.username), so
# the note stays attributable even if the user is later deleted.
username: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
augmented_part: Mapped[AugmentedPart] = relationship(back_populates="notes")
+1 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
class FitLabel(str, enum.Enum): class FitLabel(enum.StrEnum):
POSSIBLE = "possible" POSSIBLE = "possible"
EXACT = "exact" EXACT = "exact"
NONE = "none" NONE = "none"
+1
View File
@@ -19,3 +19,4 @@ class ImportedPart(Base):
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None) ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None) ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None) TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
UACDiameterRangemm: Mapped[str | None] = mapped_column(nullable=True, default=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) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False) name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=False) url: Mapped[str | None] = mapped_column(nullable=True)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None) logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
+4 -4
View File
@@ -27,8 +27,8 @@ class ScopeInfo(Base):
model_suffix: Mapped[str | None] = mapped_column( model_suffix: Mapped[str | None] = mapped_column(
String, nullable=True, default=None String, nullable=True, default=None
) )
# Kept as the raw string from the sheet (e.g. "0.75", "1/16") so no precision # The lens diameter is a descriptive attribute (the "50" in "3-18x50"), not a
# or notation is lost; the parsed thread lives on its own AttachmentPoint. # connection dimension — it has no AttachmentPoint.
objective_thread_pitch: Mapped[str | None] = mapped_column( objective_lens_diameter_mm: Mapped[float | None] = mapped_column(
String, nullable=True, default=None Float, 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 from app.core.database import Base
class UncSeries(str, enum.Enum): class UncSeries(enum.StrEnum):
UNC = "UNC" UNC = "UNC"
UNF = "UNF" UNF = "UNF"
UNEF = "UNEF" UNEF = "UNEF"
+35 -2
View File
@@ -1,4 +1,6 @@
from pydantic import BaseModel, ConfigDict from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.schemas.manufacturer import ManufacturerRead from app.schemas.manufacturer import ManufacturerRead
from app.schemas.part_colour import PartColourRead from app.schemas.part_colour import PartColourRead
@@ -7,6 +9,28 @@ from app.schemas.scope_model import ScopeModelRead
from app.schemas.sub_part_type import SubPartTypeRead from app.schemas.sub_part_type import SubPartTypeRead
class AugmentedPartNoteCreate(BaseModel):
body: str
@field_validator("body")
@classmethod
def validate_body(cls, v: str) -> str:
if not v.strip():
raise ValueError("body must not be blank")
return v.strip()
class AugmentedPartNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
augmented_part_id: int
user_id: int | None
username: str
body: str
created_at: datetime
class AugmentedPartBase(BaseModel): class AugmentedPartBase(BaseModel):
sku: str | None = None sku: str | None = None
description: str description: str
@@ -15,6 +39,8 @@ class AugmentedPartBase(BaseModel):
scope_model_id: int | None = None scope_model_id: int | None = None
part_type_id: int part_type_id: int
sub_part_type_id: int | None = None sub_part_type_id: int | None = None
on_website: bool = False
preferred_part: bool = False
class AugmentedPartCreate(AugmentedPartBase): class AugmentedPartCreate(AugmentedPartBase):
@@ -29,11 +55,15 @@ class AugmentedPartUpdate(BaseModel):
scope_model_id: int | None = None scope_model_id: int | None = None
part_type_id: int | None = None part_type_id: int | None = None
sub_part_type_id: int | None = None sub_part_type_id: int | None = None
archived: bool | None = None
on_website: bool | None = None
preferred_part: bool | None = None
class AugmentedPartSyncResult(BaseModel): class AugmentedPartSyncResult(BaseModel):
created: int created: int
deleted: int updated: int
archived: int
skipped: int skipped: int
@@ -41,8 +71,11 @@ class AugmentedPartRead(AugmentedPartBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
archived: bool
uac_diameter_range_mm: str | None
colour: PartColourRead colour: PartColourRead
manufacturer: ManufacturerRead manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None scope_model: ScopeModelRead | None
part_type: PartTypeRead part_type: PartTypeRead
sub_part_type: SubPartTypeRead | None sub_part_type: SubPartTypeRead | None
notes: list[AugmentedPartNoteRead] = []
+1
View File
@@ -12,6 +12,7 @@ class ImportedPartRead(BaseModel):
ProductODmm: str | None ProductODmm: str | None
ProductODin: str | None ProductODin: str | None
TBXThreadAttribute: str | None TBXThreadAttribute: str | None
UACDiameterRangemm: str | None
class ImportedPartConfigRead(BaseModel): class ImportedPartConfigRead(BaseModel):
+12 -2
View File
@@ -1,11 +1,19 @@
from pydantic import BaseModel, ConfigDict, HttpUrl from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
def _blank_to_none(value: str | None) -> str | None:
if isinstance(value, str) and not value.strip():
return None
return value
class ManufacturerBase(BaseModel): class ManufacturerBase(BaseModel):
name: str name: str
url: HttpUrl url: HttpUrl | None = None
logo_url: HttpUrl | None = None logo_url: HttpUrl | None = None
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
class ManufacturerCreate(ManufacturerBase): class ManufacturerCreate(ManufacturerBase):
pass pass
@@ -16,6 +24,8 @@ class ManufacturerUpdate(BaseModel):
url: HttpUrl | None = None url: HttpUrl | None = None
logo_url: HttpUrl | None = None logo_url: HttpUrl | None = None
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
class ManufacturerRead(ManufacturerBase): class ManufacturerRead(ManufacturerBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+1 -1
View File
@@ -5,7 +5,7 @@ class ScopeInfoBase(BaseModel):
magnification_min: float | None = None magnification_min: float | None = None
magnification_max: float | None = None magnification_max: float | None = None
model_suffix: str | None = None model_suffix: str | None = None
objective_thread_pitch: str | None = None objective_lens_diameter_mm: float | None = None
class ScopeInfoRead(ScopeInfoBase): class ScopeInfoRead(ScopeInfoBase):
+274 -44
View File
@@ -2,13 +2,31 @@
Algorithm Algorithm
--------- ---------
Phase 1 – prune stale entries Phase 1 – archive stale entries
• Find association rows whose imported_part_id no longer exists. • Find association rows whose imported_part_id no longer exists.
• Delete those rows. • Delete those rows.
• Delete any AugmentedPart that ends up with zero remaining associations • Archive (rather than delete) any AugmentedPart that ends up with zero
(i.e. it was created purely from now-deleted ImportedParts). 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 Phase 2 – refresh entries for already-linked ImportedParts
• For each ImportedPart already linked to an AugmentedPart (from a prior
sync run), refresh the fields sync itself owns — description, colour,
and uac_diameter_range_mm (see field mapping below) — to match the
ImportedPart's current values.
• Never touches "extra"/admin-owned AugmentedPart fields: part_type_id,
sub_part_type_id, scope_model_id, manufacturer_id, sku, archived. Those
are only ever set once, at creation (phase 3) or via the admin API/UI.
• Adds an "Ocular" and/or "Objective" attachment point if the part doesn't
already have one of that name and the current ImportedPart data now
supports deriving one. Never deletes or modifies an existing attachment
point, even if the source data that produced it has since changed.
• If an AugmentedPart has more than one backing ImportedPart, the one with
the lexicographically smallest InventoryID is used as the source (this
doesn't happen via any current creation path, but the data model and
phase 1's archiving logic both allow for it).
Phase 3 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created. • Any ImportedPart not yet linked to an AugmentedPart gets one created.
• Description ← ImportedPart.Description (fallback: InventoryID) • Description ← ImportedPart.Description (fallback: InventoryID)
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed) • Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
@@ -17,13 +35,19 @@ Phase 2 – create entries for new ImportedParts
• Type ← first PartType whose name is a substring of the description • Type ← first PartType whose name is a substring of the description
(longest name wins; skipped if no match) (longest name wins; skipped if no match)
• SubType ← null • SubType ← null
• Attachment point ← always named "Occular"; one of: • Attachment point ← always named "Ocular"; one of:
- ISO/UNC thread parsed from TBXThreadAttribute (using the - ISO/UNC thread parsed from TBXThreadAttribute (using the
matching ProductMaxThreadDepth mm/in if populated), or matching ProductMaxThreadDepth mm/in if populated), or
- StaticConnection from ProductODmm/ProductODin (whichever - StaticConnection from ProductODmm/ProductODin (whichever
unit is missing is derived via mm/in conversion) if the unit is missing is derived via mm/in conversion) if the
thread attribute is absent or unparseable thread attribute is absent or unparseable
Skipped entirely if none of those fields are usable. Skipped entirely if none of those fields are usable.
UAC diameter range ← ImportedPart.UACDiameterRangemm, an 8-digit string with
no separators (2 leading digits + 2 decimal digits for the
minimum, then the same for the maximum) reformatted as
AugmentedPart.uac_diameter_range_mm = "##.##-##.##". When
present, also adds a second attachment point named
"Objective" with a ClampConnection spanning that range.
""" """
import re import re
@@ -33,6 +57,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud 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 attachment_point_name as attachment_point_name_crud
from app.crud import augmented_part as augmented_part_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 iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud from app.crud import part_colour as part_colour_crud
@@ -46,7 +72,8 @@ from app.models.part_type import PartType
from app.models.unc_thread import UncSeries from app.models.unc_thread import UncSeries
from app.schemas.augmented_part import AugmentedPartSyncResult from app.schemas.augmented_part import AugmentedPartSyncResult
ATTACHMENT_POINT_NAME = "Occular" ATTACHMENT_POINT_NAME = "Ocular"
UAC_ATTACHMENT_POINT_NAME = "Objective"
MM_PER_INCH = 25.4 MM_PER_INCH = 25.4
# e.g. # e.g.
@@ -70,6 +97,11 @@ _UNC_THREAD_RE = re.compile(
re.IGNORECASE, 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: def _parse_float(value: str | None) -> float | None:
if value is None: if value is None:
@@ -101,9 +133,59 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
"series": series, "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 return None
def _parse_uac_diameter_range(value: str | None) -> tuple[str, float, float] | None:
"""Parse an 8-digit UACDiameterRangemm value into ("##.##-##.##", min, max).
The source field is 8 digits with no separators: the first 4 are the
minimum diameter (2 whole + 2 decimal), the last 4 the maximum, e.g.
"03505500" -> ("03.50-55.00", 3.5, 55.0).
"""
if not value:
return None
digits = "".join(re.findall(r"\d", value))
if len(digits) != 8:
return None
min_str = f"{digits[0:2]}.{digits[2:4]}"
max_str = f"{digits[4:6]}.{digits[6:8]}"
return f"{min_str}-{max_str}", float(min_str), float(max_str)
async def _add_uac_clamp_attachment_point(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> None:
"""Add a second, "Objective"-named clamp attachment point derived from
ImportedPart.UACDiameterRangemm, if that field is populated."""
parsed = _parse_uac_diameter_range(ip.UACDiameterRangemm)
if parsed is None:
return
_, min_mm, max_mm = parsed
min_in = min_mm / MM_PER_INCH
max_in = max_mm / MM_PER_INCH
apn = await _get_or_create_attachment_point_name(db, UAC_ATTACHMENT_POINT_NAME)
clamp = await clamp_connection_crud.get_by_values(db, min_mm=min_mm, max_mm=max_mm)
if clamp is None:
clamp = await clamp_connection_crud.create(
db, min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in
)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
clamp_connection_id=clamp.id,
)
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str): async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
apn = await attachment_point_name_crud.get_by_name(db, name) apn = await attachment_point_name_crud.get_by_name(db, name)
if apn is None: if apn is None:
@@ -144,6 +226,7 @@ async def _add_attachment_point(
attachment_point_name_id=apn.id, attachment_point_name_id=apn.id,
iso_thread_id=thread.id, iso_thread_id=thread.id,
) )
return
elif kind == "unc": elif kind == "unc":
depth_in = _parse_float(ip.ProductMaxThreadDepthin) depth_in = _parse_float(ip.ProductMaxThreadDepthin)
thread = await unc_thread_crud.get_by_values( thread = await unc_thread_crud.get_by_values(
@@ -157,9 +240,21 @@ async def _add_attachment_point(
attachment_point_name_id=apn.id, attachment_point_name_id=apn.id,
unc_thread_id=thread.id, unc_thread_id=thread.id,
) )
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: else:
# Unable to determine
pass pass
return
od_mm = _parse_float(ip.ProductODmm) od_mm = _parse_float(ip.ProductODmm)
od_in = _parse_float(ip.ProductODin) od_in = _parse_float(ip.ProductODin)
@@ -206,6 +301,8 @@ async def sync_one(
if manufacturer is None: if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name="Armament", url="") manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart( ap = AugmentedPart(
sku=ip.InventoryID, sku=ip.InventoryID,
description=description, description=description,
@@ -214,6 +311,7 @@ async def sync_one(
scope_model_id=None, scope_model_id=None,
part_type_id=part_type_id, part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id, sub_part_type_id=sub_part_type_id,
uac_diameter_range_mm=uac_range[0] if uac_range else None,
) )
db.add(ap) db.add(ap)
await db.flush() await db.flush()
@@ -225,6 +323,7 @@ async def sync_one(
) )
) )
await _add_attachment_point(db, ap, ip) await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
await db.commit() await db.commit()
return ap return ap
@@ -242,6 +341,137 @@ def _infer_part_type(description: str, part_types: list[PartType]) -> PartType |
return None 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 _refresh_augmented_part(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> bool:
"""Refresh the AugmentedPart fields sync owns (description, colour, UAC
diameter range) from the current state of `ip`, and add an "Ocular"
and/or "Objective" attachment point if the part doesn't already have one
and the current data now supports deriving one.
Never touches "extra" AugmentedPart fields (part_type_id, sub_part_type_id,
scope_model_id, manufacturer_id, sku, archived) and never deletes or
replaces an attachment point that already exists. Returns True if
anything changed.
"""
description = (ip.Description or "").strip() or ip.InventoryID
colour_name = (ip.TBXColour or "").strip() or "Unknown"
colour = await part_colour_crud.get_by_name(db, colour_name)
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
uac_value = uac_range[0] if uac_range else None
changes = {}
if ap.description != description:
changes["description"] = description
if ap.colour_id != colour.id:
changes["colour_id"] = colour.id
if ap.uac_diameter_range_mm != uac_value:
changes["uac_diameter_range_mm"] = uac_value
if changes:
await augmented_part_crud.update(db, ap, changes)
existing_points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
existing_names = {p.attachment_point_name.name for p in existing_points}
points_before = len(existing_points)
if ATTACHMENT_POINT_NAME not in existing_names:
await _add_attachment_point(db, ap, ip)
if UAC_ATTACHMENT_POINT_NAME not in existing_names:
await _add_uac_clamp_attachment_point(db, ap, ip)
points_after = await attachment_point_crud.list_for_augmented_part(db, ap.id)
return bool(changes) or len(points_after) > points_before
async def sync(db: AsyncSession) -> AugmentedPartSyncResult: async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1 # ------------------------------------------------------------------ phase 1
@@ -256,59 +486,53 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
current_ids: set[str] = {row[0] for row in result.all()} current_ids: set[str] = {row[0] for row in result.all()}
orphaned_import_ids = all_linked_ids - current_ids orphaned_import_ids = all_linked_ids - current_ids
deleted = 0 archived = 0
if orphaned_import_ids: if orphaned_import_ids:
# Which AugmentedParts are affected? archived = await _detach_and_archive_orphans(db, orphaned_import_ids)
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
await db.commit() await db.commit()
# ------------------------------------------------------------------ phase 2 # ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup # Re-query linked IDs after phase 1 cleanup — these are the ImportedParts
# already linked to an AugmentedPart from a prior run (not created below).
result = await db.execute( result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct() select(augmented_part_imported_parts.c.imported_part_id).distinct()
) )
now_linked_ids: set[str] = {row[0] for row in result.all()} now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids unlinked_ids = current_ids - now_linked_ids
updated = 0
if now_linked_ids:
result = await db.execute(
select(
augmented_part_imported_parts.c.augmented_part_id,
augmented_part_imported_parts.c.imported_part_id,
).where(
augmented_part_imported_parts.c.imported_part_id.in_(now_linked_ids)
)
)
ip_ids_by_ap_id: dict[int, list[str]] = {}
for ap_id, ip_id in result.all():
ip_ids_by_ap_id.setdefault(ap_id, []).append(ip_id)
for ap_id, ip_ids in ip_ids_by_ap_id.items():
ap = await db.get(AugmentedPart, ap_id)
ip = await db.get(ImportedPart, min(ip_ids))
if ap is None or ip is None:
continue
if await _refresh_augmented_part(db, ap, ip):
updated += 1
# ------------------------------------------------------------------ phase 3
created = 0 created = 0
skipped = 0 skipped = 0
if not unlinked_ids: if not unlinked_ids:
return AugmentedPartSyncResult( return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped created=created, updated=updated, archived=archived, skipped=skipped
) )
result = await db.execute( result = await db.execute(
@@ -336,6 +560,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if colour is None: if colour is None:
colour = await part_colour_crud.create(db, name=colour_name) colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart( ap = AugmentedPart(
sku=ip.InventoryID, sku=ip.InventoryID,
description=description, description=description,
@@ -344,6 +570,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
scope_model_id=None, scope_model_id=None,
part_type_id=part_type.id, part_type_id=part_type.id,
sub_part_type_id=None, sub_part_type_id=None,
uac_diameter_range_mm=uac_range[0] if uac_range else None,
) )
db.add(ap) db.add(ap)
await db.flush() await db.flush()
@@ -355,8 +582,11 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
) )
) )
await _add_attachment_point(db, ap, ip) await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
created += 1 created += 1
await db.commit() await db.commit()
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped) return AugmentedPartSyncResult(
created=created, updated=updated, archived=archived, skipped=skipped
)
+12 -3
View File
@@ -1,5 +1,7 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI.""" """Token issuance / rotation shared by the JSON API and the HTMX UI."""
from datetime import timedelta
import jwt import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,9 +15,16 @@ from app.crud import token as token_crud
from app.models.user import User from app.models.user import User
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: async def issue_token_pair(
"""Create an access + refresh token and persist the refresh token for revocation.""" db: AsyncSession, user: User, access_expires_delta: timedelta | None = None
access = create_access_token(user.id) ) -> tuple[str, str]:
"""Create an access + refresh token and persist the refresh token for revocation.
``access_expires_delta`` overrides the access token's default lifetime
(``settings.access_token_expire_minutes``) — used by the web login flow to
issue a longer-lived session cookie without affecting API bearer tokens.
"""
access = create_access_token(user.id, access_expires_delta)
refresh, payload = create_refresh_token(user.id) refresh, payload = create_refresh_token(user.id)
await token_crud.store( await token_crud.store(
db, db,
+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
+13 -7
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: def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
"""True if these two attachment points satisfy the fit-matching algorithm.""" """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 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 ( return (
_iso_match(ap_a, ap_b) _iso_match(ap_a, ap_b)
or _unc_match(ap_a, ap_b) or _unc_match(ap_a, ap_b)
@@ -142,9 +148,7 @@ async def _reconcile(
for own_ap in own_points: for own_ap in own_points:
for other_ap in points_by_part.get(other_id, []): for other_ap in points_by_part.get(other_id, []):
if attachment_points_match(own_ap, other_ap): if attachment_points_match(own_ap, other_ap):
candidate_pairs.add( candidate_pairs.add(_canonical_pair(own_ap.id, other_ap.id))
_canonical_pair(own_ap.id, other_ap.id)
)
result = RecomputeResult() result = RecomputeResult()
@@ -171,7 +175,9 @@ async def _reconcile(
return result return result
async def recompute_for_part(db: AsyncSession, augmented_part_id: int) -> RecomputeResult: async def recompute_for_part(
db: AsyncSession, augmented_part_id: int
) -> RecomputeResult:
"""Reconcile Possible fits involving this part against the current algorithm. """Reconcile Possible fits involving this part against the current algorithm.
Creates Possible fits for newly-matching attachment point pairs, and removes Creates Possible fits for newly-matching attachment point pairs, and removes
+96
View File
@@ -0,0 +1,96 @@
"""DB-backed tracking for long-running admin operations.
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
import asyncio
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
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)
async def get(db: AsyncSession, job_id: str) -> Job | None:
return await db.get(Job, job_id)
async def start(kind: str, run: Callable[[str], Awaitable[None]]) -> Job:
"""Create a job row, launch ``run(job_id)`` in the background, and return
the job immediately.
``run`` is responsible for calling ``mark_done``/``mark_error`` to record
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.
"""
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.id)
except Exception as exc:
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 import logging
from dataclasses import dataclass from dataclasses import dataclass
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@@ -43,7 +42,7 @@ def _bind_and_fetch(username: str, password: str) -> LdapIdentity | None:
) )
try: try:
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True) 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) logging.exception("Failed to log into LDAP for user %s", user_dn)
# Wrong credentials, unreachable server, or a refused bind. # Wrong credentials, unreachable server, or a refused bind.
return None return None
+33 -10
View File
@@ -7,6 +7,7 @@ the parts of that type that fit the row's scope.
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import augmented_part as augmented_part_crud from app.crud import augmented_part as augmented_part_crud
from app.crud import fit as fit_crud from app.crud import fit as fit_crud
from app.crud import part_type as part_type_crud from app.crud import part_type as part_type_crud
@@ -14,6 +15,7 @@ from app.crud import scope_info as scope_info_crud
from app.models.fit import FitLabel from app.models.fit import FitLabel
SCOPE_TYPE_NAME = "Scope" SCOPE_TYPE_NAME = "Scope"
OBJECTIVE_ATTACHMENT_POINT_NAME = "Objective"
# "exact" outranks "possible" when the same part fits a scope via several fits. # "exact" outranks "possible" when the same part fits a scope via several fits.
_LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1} _LABEL_RANK = {FitLabel.EXACT.value: 2, FitLabel.POSSIBLE.value: 1}
@@ -27,13 +29,21 @@ async def build(
scope_model_id: int | None = None, scope_model_id: int | None = None,
sub_part_type_id: int | None = None, sub_part_type_id: int | None = None,
fits_part_type_id: int | None = None, fits_part_type_id: int | None = None,
only_exact: bool = False,
) -> dict: ) -> dict:
"""Return the context for ``scopes.html``. """Return the context for ``scopes.html``.
The scope rows are narrowed by the scope-attribute filters (``description`` The scope rows are narrowed by the scope-attribute filters (``description``
matches the scope's description; the ``*_id`` filters match exactly). matches either the scope's description or its SKU/product ID; the ``*_id``
``fits_part_type_id`` keeps only scopes that have at least one (non-``none``) filters match exactly). ``fits_part_type_id`` keeps only scopes that have
fit for that part type. at least one (non-``none``)
fit for that part type. Callers that paginate should slice ``rows``
themselves — this always returns the full filtered set, since
``fits_part_type_id`` is a post-filter that can't be pushed into the DB query.
``only_exact`` drops ``possible`` fits entirely, keeping only user-confirmed
``exact`` ones — used for anonymous visitors, who shouldn't see unconfirmed
fit candidates.
Shape:: Shape::
@@ -43,6 +53,7 @@ async def build(
{ {
"scope": AugmentedPart, "scope": AugmentedPart,
"info": ScopeInfo | None, "info": ScopeInfo | None,
"objective_attachment_point": AttachmentPoint | None,
"cells": {part_type_id: [{"part": AugmentedPart, "label": str}]}, "cells": {part_type_id: [{"part": AugmentedPart, "label": str}]},
}, },
..., ...,
@@ -50,9 +61,7 @@ async def build(
} }
""" """
part_types = await part_type_crud.list_all(db, limit=1000) part_types = await part_type_crud.list_all(db, limit=1000)
scope_type = next( scope_type = next((pt for pt in part_types if pt.name == SCOPE_TYPE_NAME), None)
(pt for pt in part_types if pt.name == SCOPE_TYPE_NAME), None
)
column_types = [ column_types = [
pt for pt in part_types if scope_type is None or pt.id != scope_type.id pt for pt in part_types if scope_type is None or pt.id != scope_type.id
] ]
@@ -76,6 +85,8 @@ async def build(
fits = await fit_crud.list_for_parts( fits = await fit_crud.list_for_parts(
db, list(scope_ids), exclude_label=FitLabel.NONE 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. # Resolve the "other" (non-scope) part on each fit in a single batched query.
other_ids: set[int] = set() other_ids: set[int] = set()
@@ -87,6 +98,13 @@ async def build(
p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids)) p.id: p for p in await augmented_part_crud.get_many(db, list(other_ids))
} }
objective_aps = {
ap.augmented_part_id: ap
for ap in await attachment_point_crud.list_by_augmented_parts_and_name(
db, list(scope_ids), OBJECTIVE_ATTACHMENT_POINT_NAME
)
}
infos = { infos = {
info.augmented_part_id: info info.augmented_part_id: info
for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids)) for info in await scope_info_crud.list_by_augmented_parts(db, list(scope_ids))
@@ -94,9 +112,7 @@ async def build(
# cell_map[scope_id][type_id][other_part_id] -> {"part", "label"}, dedup'd so a # cell_map[scope_id][type_id][other_part_id] -> {"part", "label"}, dedup'd so a
# part appears once per cell, keeping its strongest fit label. # part appears once per cell, keeping its strongest fit label.
cell_map: dict[int, dict[int, dict[int, dict]]] = { cell_map: dict[int, dict[int, dict[int, dict]]] = {sid: {} for sid in scope_ids}
sid: {} for sid in scope_ids
}
for fit in fits: for fit in fits:
scope_id, other_id = _scope_and_other(fit, scope_ids) scope_id, other_id = _scope_and_other(fit, scope_ids)
if scope_id is None: if scope_id is None:
@@ -104,6 +120,8 @@ async def build(
other = other_parts.get(other_id) other = other_parts.get(other_id)
if other is None or other.part_type_id == scope_type.id: if other is None or other.part_type_id == scope_type.id:
continue # scope-to-scope fits have no column 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, {}) by_type = cell_map[scope_id].setdefault(other.part_type_id, {})
existing = by_type.get(other.id) existing = by_type.get(other.id)
label = fit.label.value label = fit.label.value
@@ -121,7 +139,12 @@ async def build(
if fits_part_type_id is not None and fits_part_type_id not in cells: if fits_part_type_id is not None and fits_part_type_id not in cells:
continue # scope has no fit for the required accessory type continue # scope has no fit for the required accessory type
rows.append( rows.append(
{"scope": scope, "info": infos.get(scope.id), "cells": cells} {
"scope": scope,
"info": infos.get(scope.id),
"objective_attachment_point": objective_aps.get(scope.id),
"cells": cells,
}
) )
return {"column_types": column_types, "rows": rows} return {"column_types": column_types, "rows": rows}
+133 -47
View File
@@ -2,7 +2,7 @@
The spreadsheet describes parts of PartType "Scope". Each row maps onto an The spreadsheet describes parts of PartType "Scope". Each row maps onto an
AugmentedPart (keyed on ``product_number`` → ``sku``, so re-importing upserts), AugmentedPart (keyed on ``product_number`` → ``sku``, so re-importing upserts),
a ScopeModel, a ScopeInfo record for the scope-specific fields, up to three a ScopeModel, a ScopeInfo record for the scope-specific fields, up to two
attachment points, and up to two Fits against other parts. attachment points, and up to two Fits against other parts.
Column → destination Column → destination
@@ -14,9 +14,8 @@ model_suffix → ScopeInfo.model_suffix (+ description)
sub_part_type → SubPartType (get-or-create under "Scope") sub_part_type → SubPartType (get-or-create under "Scope")
product_number → AugmentedPart.sku (also the upsert / fit-lookup key) product_number → AugmentedPart.sku (also the upsert / fit-lookup key)
magnification_min / _max → ScopeInfo magnification_min / _max → ScopeInfo
objective_thread_pitch → ScopeInfo objective_lens_diameter_mm → ScopeInfo (descriptive only, no attachment point)
ocular_outer_diameter_mm → StaticConnection + AttachmentPoint "Ocular" ocular_outer_diameter_mm → StaticConnection + AttachmentPoint "Ocular"
objective_lens_outside_diameter_mm → StaticConnection + AttachmentPoint "Objective"
objective_thread → ISO/UNC thread + AttachmentPoint "Objective" objective_thread → ISO/UNC thread + AttachmentPoint "Objective"
ocular_fit → Fit: scope's Ocular AP ↔ referenced part's first AP ocular_fit → Fit: scope's Ocular AP ↔ referenced part's first AP
objective_fit → Fit: scope's Objective AP ↔ referenced part's first AP objective_fit → Fit: scope's Objective 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 Fits are created with label POSSIBLE. The referenced part is looked up by
``product_number`` and linked to its first attachment point (chosen answer: ``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 "sole/first AP"). If the referenced part has no attachment points at all, one
two scopes in the same spreadsheet resolves regardless of row order. 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 Bad rows (missing key, unknown fit target, unparseable thread) are skipped and
reported per-row; good rows are committed as they go. reported per-row; good rows are committed as they go.
@@ -68,14 +74,13 @@ EXPECTED_COLUMNS = [
"model", "model",
"magnification_min", "magnification_min",
"magnification_max", "magnification_max",
"objective_lens_outside_diameter_mm", "objective_lens_diameter_mm",
"model_suffix", "model_suffix",
"sub_part_type", "sub_part_type",
"product_number", "product_number",
"url", "url",
"ocular_outer_diameter_mm", "ocular_outer_diameter_mm",
"ocular_fit", "ocular_fit",
"objective_thread_pitch",
"objective_thread", "objective_thread",
"objective_fit", "objective_fit",
] ]
@@ -194,6 +199,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( async def _link_fit(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -203,13 +256,13 @@ async def _link_fit(
scope_part_id: int, scope_part_id: int,
messages: list[str], messages: list[str],
) -> None: ) -> None:
"""Create a POSSIBLE fit from the scope's AP to the referenced part's first AP.""" """Create a POSSIBLE fit from the scope's AP to the referenced part's first AP.
if scope_ap is None:
messages.append( ``scope_ap`` must already exist — callers ensure this via
f"{scope_role}_fit '{target_sku}' skipped: scope has no " ``_ensure_manual_scope_ap`` before calling. If the referenced part has no
f"{scope_role} attachment point" attachment points at all, one is created mirroring the scope's attachment
) point's connection.
return """
target = await augmented_part_crud.get_by_sku(db, target_sku) target = await augmented_part_crud.get_by_sku(db, target_sku)
if target is None: if target is None:
messages.append(f"{scope_role}_fit references unknown product '{target_sku}'") messages.append(f"{scope_role}_fit references unknown product '{target_sku}'")
@@ -218,13 +271,16 @@ async def _link_fit(
messages.append(f"{scope_role}_fit '{target_sku}' skipped: self-reference") messages.append(f"{scope_role}_fit '{target_sku}' skipped: self-reference")
return return
target_aps = await attachment_point_crud.list_for_augmented_part(db, target.id) target_aps = await attachment_point_crud.list_for_augmented_part(db, target.id)
if not target_aps: if 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( messages.append(
f"{scope_role}_fit '{target_sku}' skipped: referenced part has no " f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
"attachment points" "created one matching the scope's attachment point"
) )
return
target_ap = target_aps[0]
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap) existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
if existing is None: if existing is None:
await fit_crud.create( await fit_crud.create(
@@ -250,7 +306,23 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
for offset, row in enumerate(rows): for offset, row in enumerate(rows):
# +2: 1-based, and the header occupies row 1. # +2: 1-based, and the header occupies row 1.
row_num = offset + 2 row_num = offset + 2
product_number = _text(row.get("product_number")) or _text(row.get("model")) model_suffix = _text(row.get("model_suffix"))
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( result = ScopeImportRowResult(
row=row_num, product_number=product_number, status="skipped", messages=[] row=row_num, product_number=product_number, status="skipped", messages=[]
) )
@@ -330,7 +402,7 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
magnification_min=_num(row.get("magnification_min")), magnification_min=_num(row.get("magnification_min")),
magnification_max=_num(row.get("magnification_max")), magnification_max=_num(row.get("magnification_max")),
model_suffix=model_suffix, model_suffix=model_suffix,
objective_thread_pitch=_text(row.get("objective_thread_pitch")), objective_lens_diameter_mm=_num(row.get("objective_lens_diameter_mm")),
) )
# Rebuild attachment points from scratch so re-imports stay idempotent # Rebuild attachment points from scratch so re-imports stay idempotent
@@ -350,34 +422,22 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
od_mm=ocular_od, od_mm=ocular_od,
) )
objective_static_ap = None # The objective attachment point comes from the thread alone — the
objective_od = _num(row.get("objective_lens_outside_diameter_mm")) # lens diameter is a descriptive attribute, not a connection.
if objective_od is not None:
objective_static_ap = await _static_ap(
db,
augmented_part_id=part.id,
label=OBJECTIVE_LABEL,
od_mm=objective_od,
)
objective_thread_spec = _text(row.get("objective_thread")) objective_thread_spec = _text(row.get("objective_thread"))
objective_thread_ap = None objective_ap = None
if objective_thread_spec: if objective_thread_spec:
objective_thread_ap = await _thread_ap( objective_ap = await _thread_ap(
db, db,
augmented_part_id=part.id, augmented_part_id=part.id,
label=OBJECTIVE_LABEL, label=OBJECTIVE_LABEL,
spec=objective_thread_spec, spec=objective_thread_spec,
) )
if objective_thread_ap is None: if objective_ap is None:
result.messages.append( result.messages.append(
f"could not parse objective_thread " f"could not parse objective_thread '{objective_thread_spec}'"
f"'{objective_thread_spec}'"
) )
# For objective_fit prefer the lens-OD point, falling back to the thread.
objective_ap = objective_static_ap or objective_thread_ap
staged.append((row, ocular_ap, objective_ap, part.id, result)) staged.append((row, ocular_ap, objective_ap, part.id, result))
row_results.append(result) row_results.append(result)
except Exception as exc: # noqa: BLE001 - report, don't abort the batch except Exception as exc: # noqa: BLE001 - report, don't abort the batch
@@ -391,24 +451,50 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
try: try:
ocular_fit = _text(row.get("ocular_fit")) ocular_fit = _text(row.get("ocular_fit"))
if ocular_fit: if ocular_fit:
await _link_fit( ocular_ap = await _ensure_manual_scope_ap(
db, db,
scope_ap=ocular_ap, scope_ap=ocular_ap,
scope_role=OCULAR_LABEL.lower(), augmented_part_id=part_id,
target_sku=ocular_fit, label=OCULAR_LABEL,
scope_part_id=part_id,
messages=result.messages, 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,
scope_role=OCULAR_LABEL.lower(),
target_sku=ocular_fit,
scope_part_id=part_id,
messages=result.messages,
)
objective_fit = _text(row.get("objective_fit")) objective_fit = _text(row.get("objective_fit"))
if objective_fit: if objective_fit:
await _link_fit( objective_ap = await _ensure_manual_scope_ap(
db, db,
scope_ap=objective_ap, scope_ap=objective_ap,
scope_role=OBJECTIVE_LABEL.lower(), augmented_part_id=part_id,
target_sku=objective_fit, label=OBJECTIVE_LABEL,
scope_part_id=part_id,
messages=result.messages, 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,
scope_role=OBJECTIVE_LABEL.lower(),
target_sku=objective_fit,
scope_part_id=part_id,
messages=result.messages,
)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
result.messages.append(f"fit resolution error: {exc}") result.messages.append(f"fit resolution error: {exc}")
+144 -34
View File
@@ -247,6 +247,37 @@ h2 {
gap: 0.4rem; gap: 0.4rem;
} }
/* Sortable table headers */
table.sortable thead th:not([data-sort-off]) {
cursor: pointer;
user-select: none;
}
table.sortable thead th:not([data-sort-off]):hover {
color: var(--text);
}
table.sortable thead th[aria-sort]::after {
margin-left: 0.3rem;
}
table.sortable thead th[aria-sort="ascending"]::after {
content: "\25B2";
}
table.sortable thead th[aria-sort="descending"]::after {
content: "\25BC";
}
.sort-link {
color: inherit;
text-decoration: none;
}
.sort-link:hover {
text-decoration: underline;
}
/* Tags / definition lists */ /* Tags / definition lists */
.tag { .tag {
display: inline-block; display: inline-block;
@@ -455,43 +486,89 @@ textarea:focus {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.ap-item-name { font-weight: 500; flex: 0 0 auto; } .ap-item-name {
.ap-item-type { color: var(--muted); flex: 0 0 auto; font-size: 0.8rem; } font-weight: 500;
.ap-item-conn { flex: 1; } flex: 0 0 auto;
.ap-item .row-actions { margin-left: auto; }
.fit-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
} }
.fit-item:last-of-type { border-bottom: none; } .ap-item-type {
color: var(--muted);
.fit-item-main { flex: 0 0 auto;
display: flex; font-size: 0.8rem;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.3rem;
} }
.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 { .fit-badge {
font-size: 0.7rem;
text-transform: uppercase; 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); } /* Fits table on the augmented part detail page */
.fit-badge-exact { background: #16a34a26; color: #16a34a; } .fit-table select,
.fit-badge-none { background: #dc262626; color: #dc2626; } .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. */ /* Scope guide: stacked matching-part entries within a fit column cell. */
.fit-cell-item { .fit-cell-item {
@@ -501,14 +578,33 @@ textarea:focus {
font-size: 0.85rem; font-size: 0.85rem;
white-space: nowrap; white-space: nowrap;
} }
.fit-cell-item + .fit-cell-item { margin-top: 0.3rem; }
.fit-note { font-size: 0.85rem; margin-bottom: 0.3rem; } .fit-cell-item+.fit-cell-item {
.fit-note-meta { color: var(--muted); font-size: 0.75rem; } margin-top: 0.3rem;
.fit-note-body { margin: 0.1rem 0 0; } }
.fit-note-form { margin-top: 0.3rem; } .fit-note {
.fit-note-form input[type="text"] { flex: 1; min-width: 12rem; } 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 */
.pagination { .pagination {
@@ -536,6 +632,18 @@ textarea:focus {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
/* Bulk actions (e.g. delete-selected above a checkbox table) */
.bulk-actions {
margin-bottom: 0.75rem;
}
.dashboard-sync-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.filter-bar form { .filter-bar form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -634,7 +742,9 @@ textarea:focus {
gap: 0.4rem; 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 { .local-login-panel summary::before {
content: '›'; content: '›';
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+77
View File
@@ -0,0 +1,77 @@
(function () {
"use strict";
function getCellText(row, index) {
var cell = row.children[index];
return cell ? cell.textContent.trim() : "";
}
function isEmptyValue(text) {
return text === "" || text === "—";
}
function isPureNumber(text) {
return /^-?\d+(\.\d+)?$/.test(text);
}
function columnIsNumeric(rows, index) {
var sawValue = false;
for (var i = 0; i < rows.length; i++) {
var text = getCellText(rows[i], index);
if (isEmptyValue(text)) continue;
sawValue = true;
if (!isPureNumber(text)) return false;
}
return sawValue;
}
function compareRows(a, b, index, numeric, dir) {
var textA = getCellText(a, index);
var textB = getCellText(b, index);
var emptyA = isEmptyValue(textA);
var emptyB = isEmptyValue(textB);
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
var result = numeric
? parseFloat(textA) - parseFloat(textB)
: textA.localeCompare(textB, undefined, { numeric: true, sensitivity: "base" });
return dir === "desc" ? -result : result;
}
function sortTable(table, th) {
var thead = table.tHead;
var tbody = table.tBodies[0];
if (!thead || !tbody) return;
var headerRow = thead.rows[0];
var index = Array.prototype.indexOf.call(headerRow.children, th);
if (index === -1) return;
var rows = Array.prototype.slice.call(tbody.rows);
if (rows.length < 2) return;
var nextDir = th.getAttribute("data-sort-dir") === "asc" ? "desc" : "asc";
Array.prototype.forEach.call(headerRow.children, function (cell) {
cell.removeAttribute("data-sort-dir");
cell.removeAttribute("aria-sort");
});
th.setAttribute("data-sort-dir", nextDir);
th.setAttribute("aria-sort", nextDir === "asc" ? "ascending" : "descending");
var numeric = columnIsNumeric(rows, index);
rows.sort(function (a, b) {
return compareRows(a, b, index, numeric, nextDir);
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
document.addEventListener("click", function (event) {
var th = event.target.closest("table.sortable thead th");
if (!th || th.hasAttribute("data-sort-off") || th.textContent.trim() === "") return;
sortTable(th.closest("table"), th);
});
})();
+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 su = user.is_superuser if user else False %}
{% set p = user.permissions %} {% 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_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_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_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_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_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 icon sprite ───────────────────────────────────────────────────────── #}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
@@ -145,6 +146,9 @@
{% if su or "audit_log:read" in p %} {% if su or "audit_log:read" in p %}
<a href="/admin/audit-log">{{ icon("list-checks") }} Audit Log</a> <a href="/admin/audit-log">{{ icon("list-checks") }} Audit Log</a>
{% endif %} {% endif %}
{% if su %}
<a href="/admin/danger-zone">{{ icon("alert-circle") }} Danger Zone</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -229,8 +233,12 @@
<a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a> <a href="/docs" target="_blank">{{ icon("book-open") }} API docs</a>
</nav> </nav>
{% if user %}
<form method="post" action="/logout" class="logout"> <form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span> <span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button> <button type="submit" class="btn btn-ghost">Sign out</button>
</form> </form>
{% else %}
<a href="/login" class="btn btn-ghost">Sign in</a>
{% endif %}
</header> </header>
+28 -17
View File
@@ -1,27 +1,36 @@
{% from "_connection_macros.html" import connection_spec %}
{%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%} {%- macro mag(v) -%}{{ v | int if v == v | int else v | round(1) }}{%- endmacro -%}
{%- macro magnification(info) -%} {%- macro magnification_range(info) -%}
{%- if info and info.magnification_min is not none and info.magnification_max is not none -%} {%- if info and info.magnification_min is not none and info.magnification_max is not none -%}
{{ mag(info.magnification_min) }}–{{ mag(info.magnification_max) }}× {{ mag(info.magnification_min) }}-{{ mag(info.magnification_max) }}
{%- elif info and info.magnification_min is not none -%} {%- elif info and info.magnification_min is not none -%}
{{ mag(info.magnification_min) }}× {{ mag(info.magnification_min) }}
{%- elif info and info.magnification_max is not none -%} {%- elif info and info.magnification_max is not none -%}
{{ mag(info.magnification_max) }}× {{ mag(info.magnification_max) }}
{%- endif -%}
{%- endmacro -%}
{%- macro magnification(info) -%}
{%- set range = magnification_range(info) -%}
{%- if range -%}
{{ range }}x{% if info.objective_lens_diameter_mm is not none %}{{ mag(info.objective_lens_diameter_mm) }}{% endif %}
{%- else -%} {%- else -%}
<span class="muted">—</span> <span class="muted">—</span>
{%- endif -%} {%- endif -%}
{%- endmacro -%} {%- endmacro -%}
<div id="scopes-table-card" class="card" style="overflow-x:auto"> <div id="scopes-table-card" class="card" style="overflow-x:auto">
<table class="table"> {% include "admin/_pagination.html" %}
<table class="table sortable">
<thead> <thead>
<tr> <tr>
{% if can_write_scopes %}
<th data-sort-off><input type="checkbox" id="scopes-select-all" title="Select all"></th>
{% endif %}
<th>Scope</th> <th>Scope</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Type</th> <th>Type</th>
<th>Magnification</th> <th>Magnification</th>
<th>Objective thread</th> <th>Objective thread</th>
{% for ct in column_types %} {% for ct in column_types %}
<th>{{ ct.name }}</th> <th data-sort-off>{{ ct.name }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@@ -29,28 +38,30 @@
{% for row in rows %} {% for row in rows %}
{% set scope = row.scope %} {% set scope = row.scope %}
{% set info = row.info %} {% set info = row.info %}
<tr> <tr id="scope-row-{{ scope.id }}">
{% if can_write_scopes %}
<td><input type="checkbox" name="scope_ids" value="{{ scope.id }}"></td>
{% endif %}
<td> <td>
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong> <strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %} {% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
</td> </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>{{ scope.sub_part_type.name if scope.sub_part_type else "—" }}</td>
<td>{{ magnification(info) }}</td> <td>{{ magnification(info) }}</td>
<td> <td>
{% if info and info.objective_thread_pitch %} {% if row.objective_attachment_point %}
{{ info.objective_thread_pitch }} {{ connection_spec(row.objective_attachment_point) }}
{% else %}<span class="muted">—</span>{% endif %} {% else %}<span class="muted">—</span>{% endif %}
</td> </td>
{% for ct in column_types %} {% for ct in column_types %}
{% set entries = row.cells.get(ct.id) %} {% set entries = row.cells.get(ct.id) %}
<td> <td>
{% if entries %} {% if entries %}
{% for e in entries %} {% for e in entries %}
<div class="fit-cell-item"> <div class="fit-cell-item">
<span class="fit-badge fit-badge-{{ e.label }}">{{ e.label | capitalize }}</span> <span class="fit-badge fit-badge-{{ e.label }}">
<a href="/admin/augmented-parts/{{ e.part.id }}">{{ e.part.description | truncate(48) }}</a> <a href="/admin/augmented-parts/{{ e.part.id }}" title="{{ e.part.description | truncate(48) }}">{{ e.part.sku }}</a>
</span>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
@@ -61,7 +72,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="{{ 6 + column_types | length }}" class="muted"> <td colspan="{{ 6 + column_types | length + (1 if can_write_scopes else 0) }}" class="muted">
No scopes match your search. No scopes match your search.
</td> </td>
</tr> </tr>
@@ -48,4 +48,6 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
{% elif connection_type == "manual" %}
<p>No connection details — fits for this attachment point must be asserted manually.</p>
{% endif %} {% endif %}
@@ -1,5 +1,5 @@
<div id="attachment-point-name-list" class="card"> <div id="attachment-point-name-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -15,12 +15,11 @@
{% set conn_type = "Static Connection" %} {% set conn_type = "Static Connection" %}
{% set conn_label = ap.static_connection.od_mm ~ " mm OD" %} {% set conn_label = ap.static_connection.od_mm ~ " mm OD" %}
{% else %} {% else %}
{% set conn_type = "—" %} {% set conn_type = "Manual" %}
{% set conn_label = "—" %} {% set conn_label = "—" %}
{% endif %} {% endif %}
<tr id="attachment-point-{{ ap.id }}"> <tr id="attachment-point-{{ ap.id }}">
<td>{{ ap.id }}</td> <td>{{ ap.id }}</td>
<td>{{ ap.augmented_part.description | truncate(40) }}</td>
<td>{{ ap.attachment_point_name.name }}</td> <td>{{ ap.attachment_point_name.name }}</td>
<td>{{ conn_type }}</td> <td>{{ conn_type }}</td>
<td><code>{{ conn_label }}</code></td> <td><code>{{ conn_label }}</code></td>
@@ -2,7 +2,7 @@
{% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %} {% elif ap.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %} {% elif ap.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif ap.static_connection_id %}{% set current_type = "static_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 }}"> <tr id="attachment-point-{{ ap.id }}">
<td colspan="6"> <td colspan="6">
<form hx-post="/admin/attachment-points/{{ ap.id }}" <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="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="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="static_connection" {% if current_type == "static_connection" %}selected{% endif %}>Static Connection</option>
<option value="manual" {% if current_type == "manual" %}selected{% endif %}>Manual</option>
</select> </select>
</label> </label>
{# Render the current connection select pre-filled #} {# Render the current connection select pre-filled #}
@@ -1,9 +1,8 @@
<div id="attachment-point-list" class="card" style="overflow-x:auto"> <div id="attachment-point-list" class="card" style="overflow-x:auto">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Augmented Part</th>
<th>Point Name</th> <th>Point Name</th>
<th>Connection Type</th> <th>Connection Type</th>
<th>Connection</th> <th>Connection</th>
@@ -15,7 +15,7 @@
{% set conn_type = "Static" %} {% set conn_type = "Static" %}
{% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %} {% set conn_label = apoint.static_connection.od_mm ~ " mm OD" %}
{% else %} {% else %}
{% set conn_type = "—" %} {% set conn_type = "Manual" %}
{% set conn_label = "—" %} {% set conn_label = "—" %}
{% endif %} {% endif %}
<div id="augmented-part-ap-{{ apoint.id }}" class="ap-item"> <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.unc_thread_id %}{% set current_type = "unc_thread" %}
{% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %} {% elif apoint.clamp_connection_id %}{% set current_type = "clamp_connection" %}
{% elif apoint.static_connection_id %}{% set current_type = "static_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 #} {# _attachment_point_connection_select.html expects `ap` for pre-selection #}
{% set ap = apoint %} {% set ap = apoint %}
{% set connection_type = current_type %} {% set connection_type = current_type %}
@@ -29,6 +29,7 @@
<option value="unc_thread" {{ "selected" if current_type == "unc_thread" }}>UNC Thread</option> <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="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="static_connection" {{ "selected" if current_type == "static_connection" }}>Static Connection</option>
<option value="manual" {{ "selected" if current_type == "manual" }}>Manual</option>
</select> </select>
</label> </label>
<div id="edit-ap-{{ apoint.id }}-connection-options"> <div id="edit-ap-{{ apoint.id }}-connection-options">
@@ -31,6 +31,7 @@
<option value="unc_thread">UNC Thread</option> <option value="unc_thread">UNC Thread</option>
<option value="clamp_connection">Clamp Connection</option> <option value="clamp_connection">Clamp Connection</option>
<option value="static_connection">Static Connection</option> <option value="static_connection">Static Connection</option>
<option value="manual">Manual</option>
</select> </select>
</label> </label>
<div id="ap-{{ augmented_part_id }}-add-connection-options"></div> <div id="ap-{{ augmented_part_id }}-add-connection-options"></div>
@@ -8,46 +8,22 @@
{%- elif ap.static_connection -%} {%- elif ap.static_connection -%}
Static {{ ap.static_connection.od_mm }} mm OD Static {{ ap.static_connection.od_mm }} mm OD
{%- else -%} {%- else -%}
— Manual
{%- endif -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
<div id="fit-{{ fit.id }}" class="fit-item"> <tr id="fit-{{ fit.id }}" class="fit-row">
<div class="fit-item-main"> <td><span class="fit-badge fit-badge-{{ fit.label.value }}">{{ fit.label.value | capitalize }}</span></td>
<span class="fit-badge fit-badge-{{ fit.label.value }}">{{ fit.label.value | capitalize }}</span> <td>
<span class="fit-item-side"><strong>{{ own_ap.attachment_point_name.name }}</strong> ({{ conn_desc(own_ap) }})</span> <strong>{{ own_ap.attachment_point_name.name }}</strong>
<span class="fit-item-arrow">↔</span> <div class="muted" style="font-size:0.8rem">{{ conn_desc(own_ap) }}</div>
<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> </td>
</div> <td class="fit-item-arrow">↔</td>
{% if can_write %} <td>
<div class="row-actions"> <a href="/admin/augmented-parts/{{ other_part.id }}" title="{{ other_part.description }}">{{ other_part.sku or other_part.description }}</a>
{% if fit.label.value != "exact" %} <div class="muted" style="font-size:0.8rem">{{ other_part.part_type.name }}</div>
<button class="btn btn-ghost" </td>
hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits/{{ fit.id }}/label" <td class="fit-notes-cell">
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">
{% for note in fit.notes %} {% for note in fit.notes %}
<div class="fit-note"> <div class="fit-note">
<span class="fit-note-meta">{{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }}</span> <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" hx-target="#fit-{{ fit.id }}" hx-swap="outerHTML"
class="inline-edit fit-note-form"> class="inline-edit fit-note-form">
<input type="text" name="body" placeholder="Add a note…" required> <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> </form>
{% endif %} {% endif %}
</div> </td>
</div> {% 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>
</td>
{% endif %}
</tr>
+71 -35
View File
@@ -4,13 +4,77 @@
{% if message %}<p class="alert alert-success">{{ message }}</p>{% endif %} {% if message %}<p class="alert alert-success">{{ message }}</p>{% endif %}
{% if error %}<p class="alert alert-error">{{ error }}</p>{% endif %} {% if error %}<p class="alert alert-error">{{ error }}</p>{% endif %}
{% for view in fit_views %} {% set add_form_id = "ap-" ~ augmented_part_id ~ "-fit-add-form" %}
{% with fit=view.fit, own_ap=view.own_ap, other_ap=view.other_ap, other_part=view.other_part %} {% if can_write %}
{% include "admin/_augmented_part_fit_row.html" %} <form id="{{ add_form_id }}"
{% endwith %} hx-post="/admin/augmented-parts/{{ augmented_part_id }}/fits"
{% else %} hx-target="#ap-{{ augmented_part_id }}-fits"
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No fits recorded yet.</p> hx-swap="outerHTML"></form>
{% endfor %} {% 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>
</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"
hx-include="[name='other_augmented_part_id']">
<option value="">— select —</option>
{% for p in other_parts %}
<option value="{{ p.id }}">{{ p.description | truncate(40) }}</option>
{% endfor %}
</select>
<div id="ap-{{ augmented_part_id }}-fit-other-points"></div>
</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 %} {% if can_write %}
<div class="row-actions" style="margin:0.5rem 0"> <div class="row-actions" style="margin:0.5rem 0">
@@ -19,33 +83,5 @@
<button type="submit" class="btn btn-ghost">Recompute possible fits</button> <button type="submit" class="btn btn-ghost">Recompute possible fits</button>
</form> </form>
</div> </div>
<form 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>
<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
hx-get="/admin/augmented-parts/{{ augmented_part_id }}/fits/other-points"
hx-target="#ap-{{ augmented_part_id }}-fit-other-points"
hx-trigger="change"
hx-include="[name='other_augmented_part_id']">
<option value="">— select —</option>
{% for p in other_parts %}
<option value="{{ p.id }}">{{ p.description | truncate(40) }}</option>
{% endfor %}
</select>
</label>
<div id="ap-{{ augmented_part_id }}-fit-other-points"></div>
<button type="submit" class="btn btn-primary">Add fit</button>
</form>
{% endif %} {% endif %}
</div> </div>
@@ -0,0 +1,22 @@
<div id="ap-{{ augmented_part_id }}-notes" class="ap-inline-section">
<h3>Notes</h3>
<div class="fit-notes">
{% for note in notes %}
<div class="fit-note">
<span class="fit-note-meta">{{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
<p class="fit-note-body">{{ note.body }}</p>
</div>
{% else %}
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No notes recorded yet.</p>
{% endfor %}
{% if can_write_notes %}
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/notes"
hx-target="#ap-{{ augmented_part_id }}-notes" 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>
</form>
{% endif %}
</div>
</div>
@@ -7,11 +7,26 @@
<td>{{ ap.scope_model.name if ap.scope_model else '—' }}</td> <td>{{ ap.scope_model.name if ap.scope_model else '—' }}</td>
<td>{{ ap.part_type.name }}</td> <td>{{ ap.part_type.name }}</td>
<td>{{ ap.sub_part_type.name if ap.sub_part_type else '—' }}</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>{% if ap.on_website %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
<td>{% if ap.preferred_part %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
<td class="row-actions"> <td class="row-actions">
<button class="btn btn-ghost" <button class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ ap.id }}/edit" hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
hx-target="#augmented-part-{{ ap.id }}" hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Edit</button> 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" <button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ ap.id }}" hx-delete="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}" hx-target="#augmented-part-{{ ap.id }}"
@@ -1,5 +1,5 @@
<tr id="augmented-part-{{ ap.id }}"> <tr id="augmented-part-{{ ap.id }}">
<td colspan="9"> <td colspan="12">
<form id="augmented-part-edit-form-{{ ap.id }}" <form id="augmented-part-edit-form-{{ ap.id }}"
hx-post="/admin/augmented-parts/{{ ap.id }}" hx-post="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}" hx-target="#augmented-part-{{ ap.id }}"
@@ -45,6 +45,8 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label class="check"><input type="checkbox" name="on_website" value="true" {{ "checked" if ap.on_website }}> On website</label>
<label class="check"><input type="checkbox" name="preferred_part" value="true" {{ "checked" if ap.preferred_part }}> Preferred part</label>
<div class="row-actions"> <div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost" <button type="button" class="btn btn-ghost"
@@ -10,7 +10,9 @@
hx-target="#augmented-parts-table-card" hx-target="#augmented-parts-table-card"
hx-trigger="input delay:300ms, change" hx-trigger="input delay:300ms, change"
hx-swap="outerHTML"> hx-swap="outerHTML">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}"> <input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="text" name="description" placeholder="Filter by description or SKU…" value="{{ filter_description or '' }}">
<select name="colour_id"> <select name="colour_id">
<option value="">All colours</option> <option value="">All colours</option>
{% for pc in part_colours %} {% for pc in part_colours %}
@@ -41,6 +43,8 @@
<option value="{{ spt.id }}" {{ "selected" if filter_sub_part_type_id and filter_sub_part_type_id|int == spt.id }}>{{ spt.name }}</option> <option value="{{ spt.id }}" {{ "selected" if filter_sub_part_type_id and filter_sub_part_type_id|int == spt.id }}>{{ spt.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
<label><input type="checkbox" name="on_website" {{ "checked" if on_website }}> On website</label>
</form> </form>
</div> </div>
@@ -1,16 +1,20 @@
{% from "admin/_sort_macros.html" import sort_th %}
<div id="augmented-parts-table-card" class="card"> <div id="augmented-parts-table-card" class="card">
{% include "admin/_pagination.html" %} {% include "admin/_pagination.html" %}
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>ID</th> {{ sort_th("id", "ID", sort_headers) }}
<th>SKU</th> {{ sort_th("sku", "SKU", sort_headers) }}
<th>Description</th> {{ sort_th("description", "Description", sort_headers) }}
<th>Colour</th> {{ sort_th("colour", "Colour", sort_headers) }}
<th>Manufacturer</th> {{ sort_th("manufacturer", "Manufacturer", sort_headers) }}
<th>Model</th> {{ sort_th("scope_model", "Model", sort_headers) }}
<th>Type</th> {{ sort_th("part_type", "Type", sort_headers) }}
<th>Sub Type</th> {{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
<th>Archived</th>
<th>On Website</th>
<th>Preferred</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -18,7 +22,7 @@
{% for ap in augmented_parts %} {% for ap in augmented_parts %}
{% include "admin/_augmented_part_row.html" %} {% include "admin/_augmented_part_row.html" %}
{% else %} {% else %}
<tr><td colspan="9" class="muted">No augmented parts recorded.</td></tr> <tr><td colspan="12" class="muted">No augmented parts recorded.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -1,5 +1,5 @@
<div id="clamp-connection-list" class="card"> <div id="clamp-connection-list" class="card">
<table class="table"> <table class="table sortable">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more