Compare commits

..
94 Commits
Author SHA1 Message Date
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
Ian Roddis b2d01f1ab0 Allowing attachment points to be pulled from descriptions on sync 2026-07-28 22:17:47 -03:00
Ian Roddis e24b8e5ea6 Updating CLAUDE.md 2026-07-28 22:08:13 -03:00
Ian Roddis aef60d77be Adding ability to edit attachment points on augmented item details 2026-07-28 22:04:40 -03:00
Ian Roddis 25fccd617f Redirecting deep links after login 2026-07-27 15:43:24 -03:00
Ian Roddis a7fb902611 Adding dataset 2026-07-27 15:19:48 -03:00
Ian Roddis 1b52ad04bc Adding part details page 2026-07-27 15:11:58 -03:00
Ian Roddis 2587ccbceb Adding filters and controls for scopes 2026-07-27 14:59:03 -03:00
Ian Roddis 3febc15a8f Adding top-level Scopes table 2026-07-27 14:53:23 -03:00
Ian Roddis bc8b67d585 Adding longer database wait time 2026-07-23 11:24:04 -03:00
Ian Roddis 16174fe4cb Adjusting regex for ISO and UNC threads to better support floats 2026-07-23 10:54:10 -03:00
Ian Roddis b5529e0dc5 Exposing data path to support sqlite volume maps 2026-07-20 11:00:00 -04:00
Ian Roddis b6dc6dd6b8 Adding entrypoint to seed database 2026-07-17 17:03:35 -03:00
Ian Roddis aca15db9bf Fixing string padding in import 2026-07-17 15:27:15 -03:00
Ian Roddis a0bd7f2c27 Updating makefile 2026-07-17 09:24:47 -03:00
Ian Roddis 0150c19b75 Making login screen a bit clearer 2026-07-16 22:10:36 -03:00
Ian Roddis 1180694738 Adding cur env 2026-07-16 22:05:13 -03:00
Ian Roddis 6d20db75c5 adding logging for ldap exceptions 2026-07-16 22:04:48 -03:00
Ian Roddis 69cbfa2d3f Adding scope import 2026-07-16 20:15:35 -03:00
Ian Roddis cda15df2ed Adding LDAP login 2026-07-16 17:05:30 -03:00
Ian Roddis ab39191af5 Adding fits 2026-07-14 15:24:05 -03:00
Ian Roddis 8185f1f8b3 Reformatting everything with black 2026-07-14 14:36:49 -03:00
Ian Roddis 7bd07f084a Adjusting linkages 2026-07-13 16:14:12 -03:00
Ian Roddis 87ba8cd169 Adding automatic attachment point addition 2026-07-03 09:57:10 -03:00
Ian Roddis c5d1186b95 Adding light theme 2026-07-02 15:26:45 -03:00
Ian Roddis 4785fd5f87 Adjusting styles 2026-07-02 15:19:48 -03:00
Ian Roddis e923250177 Adding pagination 2026-07-02 14:48:21 -03:00
Ian Roddis a50fccd56d Adding filtering to tables 2026-07-02 14:36:43 -03:00
Ian Roddis d67b4ceb65 Adding ability to add attachment points when editing augmented part 2026-07-02 14:13:09 -03:00
Ian Roddis ceae27a12e Adding Attachment Points 2026-07-02 14:06:18 -03:00
Ian Roddis 2767fb1c8e Adding AttachmentPointNames 2026-07-02 13:57:03 -03:00
Ian Roddis ae50ec7049 Fixing time between flyouts 2026-07-02 13:50:10 -03:00
Ian Roddis 7fcf5b3f75 Relayout of nav 2026-07-02 13:38:43 -03:00
Ian Roddis 59184124e7 Add clamp connection 2026-07-02 13:35:58 -03:00
Ian Roddis 0168b023af Moving post-import reconciliation to a separate page 2026-07-02 13:22:28 -03:00
Ian Roddis 87019ff2af Adding post-import adjustments 2026-07-02 13:12:09 -03:00
Ian Roddis c320595423 Adding sync with imported parts 2026-07-02 12:55:13 -03:00
Ian Roddis 00fb43c8ed Adding augmented parts 2026-07-02 12:38:59 -03:00
Ian Roddis 2e7002b73b adding part colour 2026-07-02 12:34:33 -03:00
Ian Roddis 422e217180 adding static connection 2026-07-02 12:18:06 -03:00
Ian Roddis eba7896893 Doing a relayout of permissions to make it a bit easier to navigate 2026-06-25 16:06:36 -03:00
Ian Roddis 1549971a13 Making containers reasonable 2026-06-25 15:54:51 -03:00
Ian Roddis 291416544e Adding OData Import 2026-06-25 15:52:21 -03:00
Ian Roddis c8696e67b5 Adding sub-parts 2026-06-25 15:14:37 -03:00
Ian Roddis 45bc5bf8f6 Adding audit log 2026-06-25 11:25:26 -03:00
Ian Roddis fe6f9cca05 Adding Part Types 2026-06-25 10:56:46 -03:00
Ian Roddis b7ad517174 Adding Manufacturers and ScopeModels 2026-06-25 10:46:39 -03:00
230 changed files with 17401 additions and 233 deletions

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
{
"permissions": {
"allow": [
"Bash(uv run *)",
"Bash(uv sync *)",
"Bash(git stash *)",
"Bash(python3 *)",
"Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")",
"Bash(PYTHONPATH=. uv run *)",
"Bash(python *)",
"Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)",
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
"Bash(docker build *)",
"Bash(git status *)",
"Bash(git -C /home/iroddis/dev/armament/fitguide status --short)",
"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)"
]
}
}
+49
View File
@@ -0,0 +1,49 @@
# Copy to .env and adjust. Every value here can also be supplied as a real
# environment variable (which takes precedence over the .env file).
# --- General ---
APP_NAME="Armament FitGuide"
DEBUG=true
# --- Security ---
# Generate a strong value, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
SECRET_KEY="change-me-please-use-a-long-random-string"
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# --- Database ---
# SQLite (zero-setup default):
DATABASE_URL="sqlite+aiosqlite:///./app.db"
# Postgres example:
# DATABASE_URL="postgresql+asyncpg://app:app@localhost:5432/app"
# Create tables on startup (handy for dev/SQLite; use Alembic in production).
AUTO_CREATE_TABLES=true
# --- Web / cookies ---
COOKIE_NAME=access_token
# Set true when serving over HTTPS.
COOKIE_SECURE=false
# --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
# username + password. On first login a local profile is provisioned using the
# username and the default role (mark one in the Roles admin UI).
LDAP_ENABLED=true
LDAP_HOST=192.168.110.10
LDAP_PORT=636
LDAP_USE_SSL=true
# "{username}" is substituted with the submitted username to form the bind DN.
LDAP_BIND_DN_TEMPLATE="CN={username},CN=Users,DC=tenarmtan,DC=com"
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=armament.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) ---
CORS_ORIGINS=["*"]
# --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@armament.com
FIRST_SUPERUSER_PASSWORD=changethis
+18
View File
@@ -25,6 +25,24 @@ AUTO_CREATE_TABLES=true
COOKIE_NAME=access_token
# Set true when serving over HTTPS.
COOKIE_SECURE=false
# How long a browser login lasts before being bounced back to /login.
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
SESSION_TIMEOUT_HOURS=24
# --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
# username + password. On first login a local profile is provisioned using the
# username and the default role (mark one in the Roles admin UI).
LDAP_ENABLED=false
LDAP_HOST=localhost
LDAP_PORT=389
LDAP_USE_SSL=false
# "{username}" is substituted with the submitted username to form the bind DN.
LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=example.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) ---
CORS_ORIGINS=["*"]
+2
View File
@@ -10,6 +10,8 @@ venv/
# Databases
*.db
*.db-shm
*.db-wal
*.sqlite3
# Test / tooling
+94 -13
View File
@@ -1,26 +1,41 @@
# CLAUDE.md
Guidance for working in this repo. Read alongside `README.md` (user-facing setup).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for working in this repo. Read alongside `README.md` (user-facing setup
for the original FastAPI skeleton this app was built from).
## What this is
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth
with refresh-token revocation, and RBAC (users → roles → permissions).
**Fitguide**: a parts-compatibility guide for Armament scopes and accessories,
built on a FastAPI skeleton that serves **both** a JSON API (`/api/*`) and
server-rendered HTML via **Jinja2 + HTMX** (`/`, `/scopes`, `/admin/*`). Async
SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation (plus optional LDAP
direct-bind login), and RBAC (users → roles → permissions). The domain layer on
top of the skeleton tracks parts, their attachment points, and which parts fit
together — see "Domain model" below.
## Commands
```bash
uv sync --extra dev # install (incl. dev tools)
uv run alembic upgrade head # apply migrations
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
uv run uvicorn app.main:app --reload # dev server on :8000
uv run pytest # tests (in-memory SQLite)
uv run pytest tests/test_foo.py::test_bar # single test
uv run ruff check . [--fix] # lint
uv run alembic upgrade head # apply migrations
uv run black . # format (ruff does not format here)
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
docker compose up --build # app + Postgres, auto-seeded
```
`Makefile` wraps the common ones (`make sync|seed|dev|test|fmt|migrate`).
`make clean` deletes the local `app.db`, re-migrates, and reseeds — the quick
way to get a clean dev database. `make seed` runs `alembic upgrade head` before
seeding, i.e. day-to-day dev relies on migrations, not just
`AUTO_CREATE_TABLES` (see "Database").
## Architecture / layering
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
@@ -28,40 +43,106 @@ Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data ac
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
`crud/`, not in routers.
`app/api/` is one router module per resource, all mounted under `/api` by
`app/api/router.py`. `app/web/` is organized differently: `pages.py` (dashboard/
login), `scopes.py` (the public-facing `/scopes` fit guide), and **`admin.py`**
— a single ~3000-line router covering every admin screen (users, roles, part
taxonomy tables, imported parts, augmented parts + detail + nested attachment
points/fits, audit log, etc.), organized into `# --- Section ---` banner
comments per resource rather than split into files. When adding an admin screen,
follow the existing banner-section convention there rather than starting a new
web router module.
## Domain model
The business domain, independent of the auth/RBAC skeleton:
- **`AugmentedPart`** (`augmented_parts`) is the generic "part" entity — a
scope, flip cover, ARD, polarizer, adapter ring, etc. — distinguished by
`PartType`/`SubPartType`, with `PartColour`, `Manufacturer`, and an optional
`ScopeModel`. "Scope" is just one `PartType` value (see `SCOPE_TYPE_NAME` in
`app/services/scope_guide.py`), not a separate table.
- **`ScopeInfo`** is a 1:1 extension of `AugmentedPart` (magnification,
objective thread, model suffix) populated only for scope-type parts.
- **`AttachmentPoint`** belongs to one `AugmentedPart`, is named via
`AttachmentPointName` (e.g. "Ocular", "Objective"), and connects via
*exactly one* of `IsoThread` / `UncThread` / `ClampConnection` /
`StaticConnection` — that "exactly one" invariant is enforced in the app
layer (CRUD/API validation), not by a DB constraint.
- **`Fit`** (`app/models/fit.py`) pairs two `AttachmentPoint`s (canonical
ordering enforced in `app/crud/fit.py` so a pair only ever has one row),
labeled `possible` / `exact` / `none`, with threaded `FitNote`s.
`app/services/fit_matching.py` computes `possible` fits automatically
(matching ISO/UNC thread specs exactly, or a static connection's dimension
falling inside a clamp connection's range); `exact`/`none` are user-asserted
and never touched by the algorithm.
- **`ImportedPart`** (`imported_parts`, PK is the string `InventoryID` — not an
int id) holds raw rows from an external inventory feed, fetched via
`app/services/odata.py` (OData v4, paginated). `app/services/
augmented_part_sync.py` reconciles these into `AugmentedPart` +
`AttachmentPoint` rows: infers `PartType` by substring match on the
description, parses `TBXThreadAttribute` against ISO/UNC regexes (falling
back to an OD-derived `StaticConnection`), and prunes `AugmentedPart`s whose
backing `ImportedPart`s have disappeared.
- **`app/services/scope_import.py`** is a separate, manually-triggered bulk
importer: reads an uploaded `.xlsx` of third-party scopes, upserting by
`product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`,
`ScopeInfo`, up to two `AttachmentPoint`s, and up to two `Fit`s per row
(see the module docstring for the full column mapping).
- **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per
Scope-type `AugmentedPart`, one column per other `PartType`, each cell
listing parts of that type with a recorded `Fit`.
## Conventions that matter
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so
attributes are safe to read after the session closes — required under async.
Preserve this when adding relationships that templates/schemas read.
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`,
`AttachmentPoint.iso_thread`, etc.) so attributes are safe to read after the
session closes — required under async. Preserve this when adding relationships
that templates/schemas read.
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
all checks (`User.is_superuser` / `User.has_permission`). Add new permission
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
- **HTMX UI-level write gating**: on top of route-level RBAC, pages that mix
read-only and read-write viewers (e.g. `augmented_part_detail.html`'s Fits and
Attachment Points sections) compute a `can_write_<thing>` bool from the
current user's permissions and wrap add/edit/delete controls in
`{% if can_write_<thing> %}` in the template, so read-only users see data but
not controls they'd get a 403 on. The route-level `web_require(...)` check
remains the real security boundary; the template flag is only for UX.
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`
(preserving the original path as `?next=`).
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
the token) — pass it straight to the model, don't `fromtimestamp` it.
- **LDAP login** (`app/services/ldap.py`) is optional (`settings.ldap_enabled`),
direct-bind, and just-in-time provisions a local `User` on first successful
login. `ldap3` is sync, so its calls run via `run_in_threadpool`.
## Database
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
on startup for dev convenience; production should use Alembic instead. `init_db()`
imports `app.models` inside the function to avoid a circular import.
on startup for dev convenience, but the day-to-day `make seed`/`make clean` flow
runs `alembic upgrade head` first — treat Alembic migrations as the source of
truth and keep them in sync with model changes. `init_db()` imports `app.models`
inside the function to avoid a circular import.
## Tests
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*`
without decorators.
(perms+roles from `scripts.seed.PERMISSIONS`/`ROLES`), `make_user`, and
`db_sessionmaker` (used directly in tests to seed domain rows — models, fits,
attachment points — beyond what `seeded`/`make_user` cover). `asyncio_mode =
"auto"` — write `async def test_*` without decorators.
## When extending
+5 -2
View File
@@ -18,8 +18,11 @@ RUN uv sync --no-dev
# Copy the application.
COPY . .
RUN chmod +x entrypoint.sh
# If sqlite3 is used
VOLUME /data
EXPOSE 8000
# Default: run the API server. docker-compose overrides this to also seed.
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server.
ENTRYPOINT ["./entrypoint.sh"]
+1
View File
@@ -21,3 +21,4 @@
- Models
- Colours
- Part Types
- Notes
+21 -1
View File
@@ -1,10 +1,14 @@
VERSION := $(shell git tag | tail -1)
IMAGE_NAME := fitguide
sync:
uv sync --extra dev
seed:
uv run alembic upgrade head
uv run python -m scripts.seed
dev:
dev: migrate
uv run uvicorn app.main:app --reload
open http://localhost:8000
@@ -17,3 +21,19 @@ migrate:
fmt:
uv run black .
clean:
rm app.db
uv run alembic upgrade head
uv run python -m scripts.seed
docker:
mkdir -p artifacts
docker build -t $(IMAGE_NAME):$(VERSION) .
docker-push: docker
docker tag $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
+2 -1
View File
@@ -149,8 +149,9 @@ Every setting is read from an environment variable first, then `.env`
|----------|---------|-------|
| `SECRET_KEY` | dev placeholder | **set a strong value in production** |
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
@@ -0,0 +1,45 @@
"""add jobs table
Revision ID: 04425ce3d4d5
Revises: a3f7c19b2d40
Create Date: 2026-08-05 15:21:09.527442
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "04425ce3d4d5"
down_revision: str | None = "a3f7c19b2d40"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"jobs",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("kind", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("jobs")
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add part_colours
Revision ID: 0c3d1a5cff1f
Revises: 5b6a7817b408
Create Date: 2026-07-02 12:22:24.106795
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0c3d1a5cff1f"
down_revision: str | None = "5b6a7817b408"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"part_colours",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.drop_table("part_colours")
@@ -0,0 +1,82 @@
"""add_fits_tables
Revision ID: 1b99be8a9b1e
Revises: adf4a5b9493e
Create Date: 2026-07-14 15:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1b99be8a9b1e"
down_revision: str | None = "adf4a5b9493e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"fits",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("attachment_point_a_id", sa.Integer(), nullable=False),
sa.Column("attachment_point_b_id", sa.Integer(), nullable=False),
sa.Column("augmented_part_a_id", sa.Integer(), nullable=False),
sa.Column("augmented_part_b_id", sa.Integer(), nullable=False),
sa.Column(
"label",
sa.Enum("possible", "exact", "none", name="fitlabel"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["attachment_point_b_id"], ["attachment_points.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["augmented_part_a_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["augmented_part_b_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
)
op.create_table(
"fit_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("fit_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(["fit_id"], ["fits.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("fit_notes")
op.drop_table("fits")
@@ -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,33 @@
"""add static_connections
Revision ID: 5b6a7817b408
Revises: 7be5ef599d3f
Create Date: 2026-07-02 11:57:13.095417
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5b6a7817b408"
down_revision: str | None = "7be5ef599d3f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"static_connections",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("od_mm", sa.Float(), nullable=False),
sa.Column("od_in", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("od_mm"),
)
def downgrade() -> None:
op.drop_table("static_connections")
@@ -6,16 +6,15 @@ Create Date: 2026-06-24 21:50:05.340235
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "627d4460a94f"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -0,0 +1,32 @@
"""add attachment_point_names
Revision ID: 70e48bda8829
Revises: c7882cedc9ae
Create Date: 2026-07-02 13:54:58.335394
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "70e48bda8829"
down_revision: str | None = "c7882cedc9ae"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"attachment_point_names",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.drop_table("attachment_point_names")
@@ -0,0 +1,46 @@
"""add_imported_parts_tables
Revision ID: 7be5ef599d3f
Revises: b60d0aa3ceda
Create Date: 2026-06-25 15:29:20.685050
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7be5ef599d3f"
down_revision: str | None = "b60d0aa3ceda"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"imported_parts",
sa.Column("InventoryID", sa.String(), nullable=False),
sa.Column("Description", sa.String(), nullable=True),
sa.Column("TBXColour", sa.String(), nullable=True),
sa.Column("ProductMaxThreadDepthmm", sa.String(), nullable=True),
sa.Column("ProductMaxThreadDepthin", sa.String(), nullable=True),
sa.Column("ProductODmm", sa.String(), nullable=True),
sa.Column("ProductODin", sa.String(), nullable=True),
sa.Column("TBXThreadAttribute", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("InventoryID"),
)
op.create_table(
"imported_part_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("odata_url", sa.String(), nullable=True),
sa.Column("username", sa.String(), nullable=True),
sa.Column("password", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("imported_part_config")
op.drop_table("imported_parts")
@@ -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")
@@ -0,0 +1,52 @@
"""add augmented_parts
Revision ID: 8bc411b5814e
Revises: 0c3d1a5cff1f
Create Date: 2026-07-02 12:37:59.457033
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8bc411b5814e"
down_revision: str | None = "0c3d1a5cff1f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"augmented_parts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("sku", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=False),
sa.Column("colour_id", sa.Integer(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("scope_model_id", sa.Integer(), nullable=True),
sa.Column("part_type_id", sa.Integer(), nullable=False),
sa.Column("sub_part_type_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["colour_id"], ["part_colours.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["scope_model_id"], ["scope_models.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["sub_part_type_id"], ["sub_part_types.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("augmented_parts")
@@ -0,0 +1,32 @@
"""add_archived_to_augmented_parts
Revision ID: 9a1c2f6e0b3d
Revises: 235d823a56ee
Create Date: 2026-08-17 21:30:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9a1c2f6e0b3d"
down_revision: str | None = "235d823a56ee"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"archived", sa.Boolean(), nullable=False, server_default=sa.false()
)
)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_column("archived")
@@ -0,0 +1,62 @@
"""add_manufacturers_table
Revision ID: a192abbf4091
Revises: 627d4460a94f
Create Date: 2026-06-25 10:32:11.398529
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a192abbf4091"
down_revision: str | None = "627d4460a94f"
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(
"manufacturers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("url", sa.String(), nullable=False),
sa.Column("logo_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# batch_alter_table's alter_column doesn't emit CREATE TYPE for the new
# Enum on its own (unlike create_table, which ties enum creation to the
# table-create DDL event) - create it explicitly first, or Postgres fails
# with `type "uncseries" does not exist`. checkfirst / no-op on SQLite.
uncseries = sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries")
uncseries.create(op.get_bind(), checkfirst=True)
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column(
"series",
existing_type=sa.VARCHAR(length=8),
type_=uncseries,
existing_nullable=False,
postgresql_using="series::uncseries",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column(
"series",
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
type_=sa.VARCHAR(length=8),
existing_nullable=False,
postgresql_using="series::varchar(8)",
)
sa.Enum(name="uncseries").drop(op.get_bind(), checkfirst=True)
op.drop_table("manufacturers")
# ### end Alembic commands ###
@@ -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")
@@ -0,0 +1,40 @@
"""add_scope_models_table
Revision ID: a52e7ca7a925
Revises: a192abbf4091
Create Date: 2026-06-25 10:35:40.227253
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a52e7ca7a925"
down_revision: str | None = "a192abbf4091"
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(
"scope_models",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("url", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("scope_models")
# ### end Alembic commands ###
@@ -0,0 +1,56 @@
"""add attachment_points
Revision ID: adf4a5b9493e
Revises: 70e48bda8829
Create Date: 2026-07-02 14:03:23.561253
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "adf4a5b9493e"
down_revision: str | None = "70e48bda8829"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"attachment_points",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("attachment_point_name_id", sa.Integer(), nullable=False),
sa.Column("iso_thread_id", sa.Integer(), nullable=True),
sa.Column("unc_thread_id", sa.Integer(), nullable=True),
sa.Column("clamp_connection_id", sa.Integer(), nullable=True),
sa.Column("static_connection_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["attachment_point_name_id"],
["attachment_point_names.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["iso_thread_id"], ["iso_threads.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["unc_thread_id"], ["unc_threads.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["clamp_connection_id"], ["clamp_connections.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["static_connection_id"], ["static_connections.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("attachment_points")
@@ -0,0 +1,36 @@
"""add_sub_part_types_table
Revision ID: b60d0aa3ceda
Revises: fd5379986da4
Create Date: 2026-06-25 11:29:20.420056
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b60d0aa3ceda"
down_revision: str | None = "fd5379986da4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"sub_part_types",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("part_type_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", "part_type_id"),
)
def downgrade() -> None:
op.drop_table("sub_part_types")
@@ -0,0 +1,37 @@
"""add augmented_part_imported_parts
Revision ID: bfbce324cd5f
Revises: 8bc411b5814e
Create Date: 2026-07-02 12:46:27.493942
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "bfbce324cd5f"
down_revision: str | None = "8bc411b5814e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"augmented_part_imported_parts",
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("imported_part_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["imported_part_id"], ["imported_parts.InventoryID"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("augmented_part_id", "imported_part_id"),
)
def downgrade() -> None:
op.drop_table("augmented_part_imported_parts")
@@ -0,0 +1,34 @@
"""add clamp_connections
Revision ID: c7882cedc9ae
Revises: bfbce324cd5f
Create Date: 2026-07-02 13:31:57.064381
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c7882cedc9ae"
down_revision: str | None = "bfbce324cd5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"clamp_connections",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("min_mm", sa.Float(), nullable=True),
sa.Column("max_mm", sa.Float(), nullable=True),
sa.Column("min_in", sa.Float(), nullable=True),
sa.Column("max_in", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("clamp_connections")
@@ -0,0 +1,54 @@
"""add LDAP user fields and default role flag
Revision ID: c9f1a2b3d4e5
Revises: 1b99be8a9b1e
Create Date: 2026-07-16 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c9f1a2b3d4e5"
down_revision: str | None = "1b99be8a9b1e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Batch mode so SQLite can alter the nullability of hashed_password.
with op.batch_alter_table("users") as batch:
batch.add_column(sa.Column("username", sa.String(length=255), nullable=True))
batch.add_column(
sa.Column(
"auth_source",
sa.String(length=16),
nullable=False,
server_default="local",
)
)
batch.alter_column(
"hashed_password", existing_type=sa.String(length=255), nullable=True
)
batch.create_index("ix_users_username", ["username"], unique=True)
op.add_column(
"roles",
sa.Column(
"is_default", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
def downgrade() -> None:
op.drop_column("roles", "is_default")
with op.batch_alter_table("users") as batch:
batch.drop_index("ix_users_username")
batch.alter_column(
"hashed_password", existing_type=sa.String(length=255), nullable=False
)
batch.drop_column("auth_source")
batch.drop_column("username")
@@ -0,0 +1,39 @@
"""add_scope_infos_table
Revision ID: d1e2f3a4b5c6
Revises: c9f1a2b3d4e5
Create Date: 2026-07-16 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d1e2f3a4b5c6"
down_revision: str | None = "c9f1a2b3d4e5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"scope_infos",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("magnification_min", sa.Float(), nullable=True),
sa.Column("magnification_max", sa.Float(), nullable=True),
sa.Column("model_suffix", sa.String(), nullable=True),
sa.Column("objective_thread_pitch", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("augmented_part_id"),
)
def downgrade() -> None:
op.drop_table("scope_infos")
@@ -0,0 +1,36 @@
"""add_part_types_table
Revision ID: e23aa8dd2536
Revises: a52e7ca7a925
Create Date: 2026-06-25 10:49:56.463466
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e23aa8dd2536"
down_revision: str | None = "a52e7ca7a925"
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(
"part_types",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("part_types")
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
"""add_audit_logs_table
Revision ID: fd5379986da4
Revises: e23aa8dd2536
Create Date: 2026-06-25 11:06:13.028067
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "fd5379986da4"
down_revision: str | None = "e23aa8dd2536"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("user_email", sa.String(length=255), nullable=False),
sa.Column("action", sa.String(length=16), nullable=False),
sa.Column("resource_type", sa.String(length=64), nullable=False),
sa.Column("resource_id", sa.Integer(), nullable=True),
sa.Column("description", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_table("audit_logs")
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point_name as apn_crud
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.attachment_point_name import (
AttachmentPointNameCreate,
AttachmentPointNameRead,
AttachmentPointNameUpdate,
)
router = APIRouter()
@router.get("", response_model=list[AttachmentPointNameRead])
async def list_attachment_point_names(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_point_names:read")),
):
return await apn_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=AttachmentPointNameRead, status_code=status.HTTP_201_CREATED
)
async def create_attachment_point_name(
payload: AttachmentPointNameCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")),
):
apn = await apn_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AttachmentPointName",
resource_id=apn.id,
description=audit_crud.describe_create("AttachmentPointName", apn.id, apn.name),
)
return apn
@router.get("/{apn_id}", response_model=AttachmentPointNameRead)
async def get_attachment_point_name(
apn_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_point_names:read")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
return apn
@router.patch("/{apn_id}", response_model=AttachmentPointNameRead)
async def update_attachment_point_name(
apn_id: int,
payload: AttachmentPointNameUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
changes = payload.model_dump(exclude_unset=True)
apn = await apn_crud.update(db, apn, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AttachmentPointName",
resource_id=apn.id,
description=audit_crud.describe_update("AttachmentPointName", apn.id, changes),
)
return apn
@router.delete("/{apn_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment_point_name(
apn_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:delete")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
name = apn.name
await apn_crud.delete(db, apn)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AttachmentPointName",
resource_id=apn_id,
description=audit_crud.describe_delete("AttachmentPointName", apn_id, name),
)
+135
View File
@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.attachment_point import (
AttachmentPointCreate,
AttachmentPointRead,
AttachmentPointUpdate,
)
router = APIRouter()
def _connection_label(ap) -> str:
if ap.iso_thread:
return f"M{ap.iso_thread.diameter_mm}×{ap.iso_thread.pitch_mm}"
if ap.unc_thread:
return f"{ap.unc_thread.diameter_fraction}-{ap.unc_thread.tpi} {ap.unc_thread.series}"
if ap.clamp_connection:
cc = ap.clamp_connection
if cc.min_mm is not None:
return f"{cc.min_mm}–{cc.max_mm} mm"
return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD"
return "Manual"
@router.get("", response_model=list[AttachmentPointRead])
async def list_attachment_points(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_points:read")),
):
return await attachment_point_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=AttachmentPointRead, status_code=status.HTTP_201_CREATED
)
async def create_attachment_point(
payload: AttachmentPointCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:write")),
):
conn_ids = [
payload.iso_thread_id,
payload.unc_thread_id,
payload.clamp_connection_id,
payload.static_connection_id,
]
if sum(v is not None for v in conn_ids) > 1:
raise HTTPException(
status_code=400,
detail=(
"At most one connection (iso_thread, unc_thread, clamp_connection, "
"or static_connection) may be provided. Leave all unset for a "
"manual attachment point."
),
)
ap = await attachment_point_crud.create(db, **payload.model_dump())
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AttachmentPoint",
resource_id=ap.id,
description=audit_crud.describe_create(
"AttachmentPoint", ap.id, _connection_label(ap)
),
)
return ap
@router.get("/{attachment_point_id}", response_model=AttachmentPointRead)
async def get_attachment_point(
attachment_point_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_points:read")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
return ap
@router.patch("/{attachment_point_id}", response_model=AttachmentPointRead)
async def update_attachment_point(
attachment_point_id: int,
payload: AttachmentPointUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:write")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
changes = payload.model_dump(exclude_unset=True)
ap = await attachment_point_crud.update(db, ap, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AttachmentPoint",
resource_id=ap.id,
description=audit_crud.describe_update("AttachmentPoint", ap.id, changes),
)
return ap
@router.delete("/{attachment_point_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment_point(
attachment_point_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:delete")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
label = _connection_label(ap)
await attachment_point_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AttachmentPoint",
resource_id=attachment_point_id,
description=audit_crud.describe_delete(
"AttachmentPoint", attachment_point_id, label
),
)
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.audit_log import AuditLogRead
router = APIRouter()
@router.get("", response_model=list[AuditLogRead])
async def list_audit_log(
limit: int = 200,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("audit_log:read")),
):
return await audit_crud.list_recent(db, limit=limit)
+159
View File
@@ -0,0 +1,159 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud
from app.crud import augmented_part_note as augmented_part_note_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.augmented_part import (
AugmentedPartCreate,
AugmentedPartNoteCreate,
AugmentedPartNoteRead,
AugmentedPartRead,
AugmentedPartSyncResult,
AugmentedPartUpdate,
)
from app.services import augmented_part_sync
router = APIRouter()
@router.get("", response_model=list[AugmentedPartRead])
async def list_augmented_parts(
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
return await augmented_part_crud.list_all(
db, skip=skip, limit=limit, include_archived=include_archived
)
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
async def create_augmented_part(
payload: AugmentedPartCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
ap = await augmented_part_crud.create(
db,
sku=payload.sku,
description=payload.description,
colour_id=payload.colour_id,
manufacturer_id=payload.manufacturer_id,
scope_model_id=payload.scope_model_id,
part_type_id=payload.part_type_id,
sub_part_type_id=payload.sub_part_type_id,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AugmentedPart",
resource_id=ap.id,
description=audit_crud.describe_create(
"AugmentedPart", ap.id, ap.sku or ap.description
),
)
return ap
@router.post("/sync", response_model=AugmentedPartSyncResult)
async def sync_augmented_parts(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
result = await augmented_part_sync.sync(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
return result
@router.get("/{augmented_part_id}", response_model=AugmentedPartRead)
async def get_augmented_part(
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
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 ap
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
async def update_augmented_part(
augmented_part_id: int,
payload: AugmentedPartUpdate,
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")
changes = payload.model_dump(exclude_unset=True)
ap = await augmented_part_crud.update(db, ap, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=ap.id,
description=audit_crud.describe_update("AugmentedPart", ap.id, changes),
)
return ap
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_augmented_part(
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:delete")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
)
@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
)
+32 -3
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import token as token_crud
@@ -10,27 +11,34 @@ from app.deps import get_current_user
from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead
from app.services import ldap as ldap_service
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user"
async def _default_role_ids(db: AsyncSession) -> list[int]:
role = await role_crud.get_default(db)
if role is None:
role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
return [role.id] if role else []
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
role_ids=role_ids,
role_ids=await _default_role_ids(db),
)
return user
@@ -52,6 +60,27 @@ async def login(
return Token(access_token=access, refresh_token=refresh)
@router.post("/ldap-login", response_model=Token)
async def ldap_login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
"""Authenticate against LDAP, provisioning the local profile on first login."""
if not settings.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="LDAP login is not enabled"
)
user = await ldap_service.login(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try:
+121
View File
@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.clamp_connection import (
ClampConnectionCreate,
ClampConnectionRead,
ClampConnectionUpdate,
)
router = APIRouter()
@router.get("", response_model=list[ClampConnectionRead])
async def list_clamp_connections(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("clamp_connections:read")),
):
return await clamp_connection_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=ClampConnectionRead, status_code=status.HTTP_201_CREATED
)
async def create_clamp_connection(
payload: ClampConnectionCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:write")),
):
cc = await clamp_connection_crud.create(
db,
min_mm=payload.min_mm,
max_mm=payload.max_mm,
min_in=payload.min_in,
max_in=payload.max_in,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="ClampConnection",
resource_id=cc.id,
description=audit_crud.describe_create(
"ClampConnection",
cc.id,
(
f"{cc.min_mm}–{cc.max_mm} mm"
if cc.min_mm is not None
else f"{cc.min_in}–{cc.max_in} in"
),
),
)
return cc
@router.get("/{clamp_connection_id}", response_model=ClampConnectionRead)
async def get_clamp_connection(
clamp_connection_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("clamp_connections:read")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
return cc
@router.patch("/{clamp_connection_id}", response_model=ClampConnectionRead)
async def update_clamp_connection(
clamp_connection_id: int,
payload: ClampConnectionUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:write")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
changes = payload.model_dump(exclude_unset=True)
cc = await clamp_connection_crud.update(db, cc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ClampConnection",
resource_id=cc.id,
description=audit_crud.describe_update("ClampConnection", cc.id, changes),
)
return cc
@router.delete("/{clamp_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_clamp_connection(
clamp_connection_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:delete")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
label = (
f"{cc.min_mm}–{cc.max_mm} mm"
if cc.min_mm is not None
else f"{cc.min_in}–{cc.max_in} in"
)
await clamp_connection_crud.delete(db, cc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ClampConnection",
resource_id=clamp_connection_id,
description=audit_crud.describe_delete(
"ClampConnection", clamp_connection_id, label
),
)
+169
View File
@@ -0,0 +1,169 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import audit as audit_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.deps import require_permission
from app.models.fit import FitLabel
from app.models.user import User
from app.schemas.fit import (
FitCreate,
FitNoteCreate,
FitNoteRead,
FitRead,
FitRecomputeResult,
FitUpdate,
)
from app.services import fit_matching
router = APIRouter()
@router.get("", response_model=list[FitRead])
async def list_fits(
augmented_part_id: int | None = None,
label: FitLabel | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
if augmented_part_id is not None:
fits = await fit_crud.list_for_part(db, augmented_part_id)
if label is not None:
fits = [f for f in fits if f.label == label]
return fits[skip : skip + limit]
return await fit_crud.list_all(db, skip=skip, limit=limit, label=label)
@router.get("/{fit_id}", response_model=FitRead)
async def get_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return fit
@router.post("", response_model=FitRead, status_code=status.HTTP_201_CREATED)
async def create_fit(
payload: FitCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Manually declare a fit between two attachment points; recorded as Exact."""
if payload.attachment_point_a_id == payload.attachment_point_b_id:
raise HTTPException(
status_code=422, detail="attachment points must be different"
)
ap_a = await attachment_point_crud.get(db, payload.attachment_point_a_id)
ap_b = await attachment_point_crud.get(db, payload.attachment_point_b_id)
if ap_a is None or ap_b is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
if ap_a.augmented_part_id == ap_b.augmented_part_id:
raise HTTPException(status_code=422, detail="a part cannot fit itself")
existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b)
if existing is not None:
raise HTTPException(status_code=409, detail="Fit already exists")
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_create(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
),
)
return fit
@router.patch("/{fit_id}", response_model=FitRead)
async def update_fit_label(
fit_id: int,
payload: FitUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Promote a Possible fit to Exact, or mark a pairing as None (impossible)."""
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
fit = await fit_crud.set_label(db, fit, payload.label)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_update(
"Fit", fit.id, {"label": payload.label.value}
),
)
return fit
@router.delete("/{fit_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:delete")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}"
await fit_crud.delete(db, fit)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Fit",
resource_id=fit_id,
description=audit_crud.describe_delete("Fit", fit_id, label),
)
@router.post("/recompute", response_model=FitRecomputeResult)
async def recompute_fits(
augmented_part_id: int | None = None,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Re-run the matching algorithm, scoped to one part or across all parts."""
if augmented_part_id is not None:
result = await fit_matching.recompute_for_part(db, augmented_part_id)
else:
result = await fit_matching.recompute_all(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=augmented_part_id,
description=f"Recomputed fits: {result.created} created, {result.removed} removed",
)
return FitRecomputeResult(created=result.created, removed=result.removed)
@router.post(
"/{fit_id}/notes", response_model=FitNoteRead, status_code=status.HTTP_201_CREATED
)
async def create_fit_note(
fit_id: int,
payload: FitNoteCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return await fit_note_crud.create(db, fit=fit, author=actor, body=payload.body)
+154
View File
@@ -0,0 +1,154 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as config_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.imported_part import (
ImportedPartConfigRead,
ImportedPartConfigUpdate,
ImportedPartRead,
SyncResult,
)
from app.services import augmented_part_sync
from app.services import odata as odata_service
router = APIRouter()
@router.get("", response_model=list[ImportedPartRead])
async def list_imported_parts(
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
return await imported_part_crud.list_all(db, skip=skip, limit=limit)
@router.get("/config", response_model=ImportedPartConfigRead)
async def get_config(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if config is None:
return ImportedPartConfigRead(odata_url=None, username=None, has_password=False)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.patch("/config", response_model=ImportedPartConfigRead)
async def update_config(
payload: ImportedPartConfigUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
current = await config_crud.get(db)
# Keep existing password if not provided in the update
new_password = (
payload.password
if payload.password is not None
else (current.password if current else None)
)
config = await config_crud.upsert(
db,
odata_url=payload.odata_url,
username=payload.username,
password=new_password,
)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ImportedPartConfig",
resource_id=1,
description="Updated ImportedPart OData configuration",
)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.post("/refresh", response_model=SyncResult)
async def refresh_imported_parts(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if not config or not config.odata_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="OData URL is not configured.",
)
records = await odata_service.fetch(
config.odata_url, config.username, config.password
)
created, updated = await imported_part_crud.upsert_bulk(db, records)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ImportedPart",
resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
)
return SyncResult(created=created, updated=updated, total=len(records))
@router.get("/{inventory_id}", response_model=ImportedPartRead)
async def get_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
return part
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:delete")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
archived = await augmented_part_sync.delete_imported_part_and_archive_orphans(
db, part
)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ImportedPart",
resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
)
if archived:
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Archived {archived} augmented part(s) left with no remaining "
f"imported parts after deleting {inventory_id!r}"
),
)
+45 -8
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import iso_thread as iso_thread_crud
from app.deps import require_permission
from app.models.user import User
@@ -24,14 +25,25 @@ async def list_iso_threads(
async def create_iso_thread(
payload: IsoThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
actor: User = Depends(require_permission("iso_threads:write")),
):
return await iso_thread_crud.create(
thread = await iso_thread_crud.create(
db,
diameter_mm=payload.diameter_mm,
pitch_mm=payload.pitch_mm,
depth_mm=payload.depth_mm,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_create(
"IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"
),
)
return thread
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
@@ -42,7 +54,9 @@ async def get_iso_thread(
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
return thread
@@ -51,21 +65,44 @@ async def update_iso_thread(
iso_thread_id: int,
payload: IsoThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
actor: User = Depends(require_permission("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
return await iso_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
changes = payload.model_dump(exclude_unset=True)
thread = await iso_thread_crud.update(db, thread, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_update("IsoThread", thread.id, changes),
)
return thread
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:delete")),
actor: User = Depends(require_permission("iso_threads:delete")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
name = f"M{thread.diameter_mm}×{thread.pitch_mm}"
await iso_thread_crud.delete(db, thread)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="IsoThread",
resource_id=iso_thread_id,
description=audit_crud.describe_delete("IsoThread", iso_thread_id, name),
)
+114
View File
@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import manufacturer as manufacturer_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.manufacturer import (
ManufacturerCreate,
ManufacturerRead,
ManufacturerUpdate,
)
router = APIRouter()
@router.get("", response_model=list[ManufacturerRead])
async def list_manufacturers(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
return await manufacturer_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ManufacturerRead, status_code=status.HTTP_201_CREATED)
async def create_manufacturer(
payload: ManufacturerCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.create(
db,
name=payload.name,
url=str(payload.url),
logo_url=str(payload.logo_url) if payload.logo_url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_create("Manufacturer", m.id, m.name),
)
return m
@router.get("/{manufacturer_id}", response_model=ManufacturerRead)
async def get_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
return m
@router.patch("/{manufacturer_id}", response_model=ManufacturerRead)
async def update_manufacturer(
manufacturer_id: int,
payload: ManufacturerUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
if "logo_url" in data and data["logo_url"] is not None:
data["logo_url"] = str(data["logo_url"])
m = await manufacturer_crud.update(db, m, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_update("Manufacturer", m.id, data),
)
return m
@router.delete("/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:delete")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
name = m.name
await manufacturer_crud.delete(db, m)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Manufacturer",
resource_id=manufacturer_id,
description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name),
)
+95
View File
@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import part_colour as part_colour_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.part_colour import PartColourCreate, PartColourRead, PartColourUpdate
router = APIRouter()
@router.get("", response_model=list[PartColourRead])
async def list_part_colours(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
return await part_colour_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=PartColourRead, status_code=status.HTTP_201_CREATED)
async def create_part_colour(
payload: PartColourCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_create("PartColour", pc.id, pc.name),
)
return pc
@router.get("/{part_colour_id}", response_model=PartColourRead)
async def get_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
return pc
@router.patch("/{part_colour_id}", response_model=PartColourRead)
async def update_part_colour(
part_colour_id: int,
payload: PartColourUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
changes = payload.model_dump(exclude_unset=True)
pc = await part_colour_crud.update(db, pc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_update("PartColour", pc.id, changes),
)
return pc
@router.delete("/{part_colour_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:delete")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
name = pc.name
await part_colour_crud.delete(db, pc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="PartColour",
resource_id=part_colour_id,
description=audit_crud.describe_delete("PartColour", part_colour_id, name),
)
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import part_type as part_type_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.part_type import PartTypeCreate, PartTypeRead, PartTypeUpdate
router = APIRouter()
@router.get("", response_model=list[PartTypeRead])
async def list_part_types(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_types:read")),
):
return await part_type_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=PartTypeRead, status_code=status.HTTP_201_CREATED)
async def create_part_type(
payload: PartTypeCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")),
):
pt = await part_type_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="PartType",
resource_id=pt.id,
description=audit_crud.describe_create("PartType", pt.id, pt.name),
)
return pt
@router.get("/{part_type_id}", response_model=PartTypeRead)
async def get_part_type(
part_type_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_types:read")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
return pt
@router.patch("/{part_type_id}", response_model=PartTypeRead)
async def update_part_type(
part_type_id: int,
payload: PartTypeUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
changes = payload.model_dump(exclude_unset=True)
pt = await part_type_crud.update(db, pt, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="PartType",
resource_id=pt.id,
description=audit_crud.describe_update("PartType", pt.id, changes),
)
return pt
@router.delete("/{part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part_type(
part_type_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:delete")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
name = pt.name
await part_type_crud.delete(db, pt)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="PartType",
resource_id=part_type_id,
description=audit_crud.describe_delete("PartType", part_type_id, name),
)
+34 -5
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import role as role_crud
from app.deps import require_permission
from app.models.user import User
@@ -22,18 +23,27 @@ async def list_roles(
async def create_role(
payload: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
if await role_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
return await role_crud.create(
role = await role_crud.create(
db,
name=payload.name,
description=payload.description,
permission_ids=payload.permission_ids,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_create("Role", role.id, role.name),
)
return role
@router.get("/permissions", response_model=list[PermissionRead])
@@ -63,25 +73,44 @@ async def update_role(
role_id: int,
payload: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
)
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
changes = payload.model_dump(exclude_unset=True)
role = await role_crud.update(db, role, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_update("Role", role.id, changes),
)
return role
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
)
name = role.name
await role_crud.delete(db, role)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Role",
resource_id=role_id,
description=audit_crud.describe_delete("Role", role_id, name),
)
+65 -3
View File
@@ -1,10 +1,72 @@
from fastapi import APIRouter
from app.api import auth, iso_threads, roles, unc_threads, users
from app.api import (
attachment_point_names,
attachment_points,
audit_log,
augmented_parts,
auth,
clamp_connections,
fits,
imported_parts,
iso_threads,
manufacturers,
part_colours,
part_types,
roles,
scope_import,
scope_models,
static_connections,
sub_part_types,
unc_threads,
users,
)
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso-threads"])
api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"])
api_router.include_router(
iso_threads.router, prefix="/iso-threads", tags=["iso-threads"]
)
api_router.include_router(
unc_threads.router, prefix="/unc-threads", tags=["unc-threads"]
)
api_router.include_router(
manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]
)
api_router.include_router(
scope_models.router, prefix="/scope-models", tags=["scope-models"]
)
api_router.include_router(
part_colours.router, prefix="/part-colours", tags=["part-colours"]
)
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
api_router.include_router(
sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]
)
api_router.include_router(
static_connections.router, prefix="/static-connections", tags=["static-connections"]
)
api_router.include_router(
clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"]
)
api_router.include_router(
attachment_point_names.router,
prefix="/attachment-point-names",
tags=["attachment-point-names"],
)
api_router.include_router(
attachment_points.router, prefix="/attachment-points", tags=["attachment-points"]
)
api_router.include_router(
imported_parts.router, prefix="/imported-parts", tags=["imported-parts"]
)
api_router.include_router(
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
)
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
api_router.include_router(
scope_import.router, prefix="/scope-import", tags=["scope-import"]
)
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_import import ScopeImportResult
from app.services import scope_import as scope_import_service
router = APIRouter()
_XLSX_TYPES = {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/octet-stream",
"",
}
@router.post("", response_model=ScopeImportResult)
async def import_scopes(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
filename = file.filename or ""
if not filename.lower().endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Expected an .xlsx spreadsheet.",
)
data = await file.read()
try:
result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # openpyxl / malformed workbook
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not read spreadsheet: {exc}",
) from exc
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Scope import ({filename}): {result.created} created, "
f"{result.updated} updated, {result.skipped} skipped, "
f"{result.errors} errors"
),
)
return result
+108
View File
@@ -0,0 +1,108 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import scope_model as scope_model_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_model import ScopeModelCreate, ScopeModelRead, ScopeModelUpdate
router = APIRouter()
@router.get("", response_model=list[ScopeModelRead])
async def list_scope_models(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
return await scope_model_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ScopeModelRead, status_code=status.HTTP_201_CREATED)
async def create_scope_model(
payload: ScopeModelCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.create(
db,
name=payload.name,
manufacturer_id=payload.manufacturer_id,
url=str(payload.url) if payload.url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_create("ScopeModel", sm.id, sm.name),
)
return sm
@router.get("/{scope_model_id}", response_model=ScopeModelRead)
async def get_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
return sm
@router.patch("/{scope_model_id}", response_model=ScopeModelRead)
async def update_scope_model(
scope_model_id: int,
payload: ScopeModelUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
sm = await scope_model_crud.update(db, sm, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_update("ScopeModel", sm.id, data),
)
return sm
@router.delete("/{scope_model_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:delete")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
name = sm.name
await scope_model_crud.delete(db, sm)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ScopeModel",
resource_id=scope_model_id,
description=audit_crud.describe_delete("ScopeModel", scope_model_id, name),
)
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import static_connection as static_connection_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.static_connection import (
StaticConnectionCreate,
StaticConnectionRead,
StaticConnectionUpdate,
)
router = APIRouter()
@router.get("", response_model=list[StaticConnectionRead])
async def list_static_connections(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
return await static_connection_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=StaticConnectionRead, status_code=status.HTTP_201_CREATED
)
async def create_static_connection(
payload: StaticConnectionCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.create(
db, od_mm=payload.od_mm, od_in=payload.od_in
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_create(
"StaticConnection", sc.id, f"{sc.od_mm} mm"
),
)
return sc
@router.get("/{static_connection_id}", response_model=StaticConnectionRead)
async def get_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
return sc
@router.patch("/{static_connection_id}", response_model=StaticConnectionRead)
async def update_static_connection(
static_connection_id: int,
payload: StaticConnectionUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
changes = payload.model_dump(exclude_unset=True)
sc = await static_connection_crud.update(db, sc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_update("StaticConnection", sc.id, changes),
)
return sc
@router.delete("/{static_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:delete")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
od_mm = sc.od_mm
await static_connection_crud.delete(db, sc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="StaticConnection",
resource_id=static_connection_id,
description=audit_crud.describe_delete(
"StaticConnection", static_connection_id, f"{od_mm} mm"
),
)
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.sub_part_type import (
SubPartTypeCreate,
SubPartTypeRead,
SubPartTypeUpdate,
)
router = APIRouter()
@router.get("", response_model=list[SubPartTypeRead])
async def list_sub_part_types(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
return await sub_part_type_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=SubPartTypeRead, status_code=status.HTTP_201_CREATED)
async def create_sub_part_type(
payload: SubPartTypeCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.create(
db, name=payload.name, part_type_id=payload.part_type_id
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_create("SubPartType", spt.id, spt.name),
)
return spt
@router.get("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def get_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
return spt
@router.patch("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def update_sub_part_type(
sub_part_type_id: int,
payload: SubPartTypeUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
changes = payload.model_dump(exclude_unset=True)
spt = await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_update("SubPartType", spt.id, changes),
)
return spt
@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:delete")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
name = spt.name
await sub_part_type_crud.delete(db, spt)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="SubPartType",
resource_id=sub_part_type_id,
description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name),
)
+47 -8
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import unc_thread as unc_thread_crud
from app.deps import require_permission
from app.models.user import User
@@ -24,15 +25,28 @@ async def list_unc_threads(
async def create_unc_thread(
payload: UncThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
actor: User = Depends(require_permission("unc_threads:write")),
):
return await unc_thread_crud.create(
thread = await unc_thread_crud.create(
db,
diameter_fraction=payload.diameter_fraction,
tpi=payload.tpi,
series=payload.series,
depth_in=payload.depth_in,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="UncThread",
resource_id=thread.id,
description=audit_crud.describe_create(
"UncThread",
thread.id,
f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}",
),
)
return thread
@router.get("/{unc_thread_id}", response_model=UncThreadRead)
@@ -43,7 +57,9 @@ async def get_unc_thread(
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
return thread
@@ -52,21 +68,44 @@ async def update_unc_thread(
unc_thread_id: int,
payload: UncThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
actor: User = Depends(require_permission("unc_threads:write")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
return await unc_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
changes = payload.model_dump(exclude_unset=True)
thread = await unc_thread_crud.update(db, thread, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="UncThread",
resource_id=thread.id,
description=audit_crud.describe_update("UncThread", thread.id, changes),
)
return thread
@router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_unc_thread(
unc_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:delete")),
actor: User = Depends(require_permission("unc_threads:delete")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"
await unc_thread_crud.delete(db, thread)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="UncThread",
resource_id=unc_thread_id,
description=audit_crud.describe_delete("UncThread", unc_thread_id, name),
)
+42 -13
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import user as user_crud
from app.deps import get_current_user, require_permission
from app.models.user import User
@@ -29,13 +30,13 @@ async def list_users(
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
actor: User = Depends(require_permission("users:write")),
):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
return await user_crud.create(
created = await user_crud.create(
db,
email=payload.email,
password=payload.password,
@@ -43,6 +44,15 @@ async def create_user(
is_superuser=payload.is_superuser,
role_ids=payload.role_ids,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="User",
resource_id=created.id,
description=audit_crud.describe_create("User", created.id, created.email),
)
return created
@router.get("/{user_id}", response_model=UserRead)
@@ -51,12 +61,12 @@ async def get_user(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
return target
@router.patch("/{user_id}", response_model=UserRead)
@@ -64,25 +74,44 @@ async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
actor: User = Depends(require_permission("users:write")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
changes = payload.model_dump(exclude_unset=True)
target = await user_crud.update(db, target, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="User",
resource_id=user_id,
description=audit_crud.describe_update("User", user_id, changes),
)
return target
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:delete")),
actor: User = Depends(require_permission("users:delete")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
await user_crud.delete(db, user)
email = target.email
await user_crud.delete(db, target)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="User",
resource_id=user_id,
description=audit_crud.describe_delete("User", user_id, email),
)
+18 -1
View File
@@ -33,9 +33,26 @@ class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
# session_timeout_hours governs how long a browser login lasts before the
# user is bounced back to /login; it's independent of the JSON API's
# short-lived access_token_expire_minutes.
cookie_name: str = "access_token"
cookie_secure: bool = False
session_timeout_hours: int = 24
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
# ``{username}`` substituted) and bound directly with their password.
ldap_enabled: bool = False
ldap_host: str = "localhost"
ldap_port: int = 389
ldap_use_ssl: bool = False
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
ldap_email_domain: str = "example.com"
# LDAP attribute read for the provisioned user's display name.
ldap_full_name_attr: str = "cn"
# CORS
cors_origins: list[str] = ["*"]
+15
View File
@@ -5,6 +5,7 @@ Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -21,6 +22,20 @@ class Base(DeclarativeBase):
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
if engine.sync_engine.dialect.name == "sqlite":
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
"""WAL mode lets readers/writers avoid blocking each other; busy_timeout
makes a connection wait for a lock instead of immediately raising
"database is locked" (the pysqlite/aiosqlite default is 0ms).
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
+4 -2
View File
@@ -47,10 +47,12 @@ def _create_token(
return token, payload
def create_access_token(subject: str | int) -> str:
def create_access_token(
subject: str | int, expires_delta: timedelta | None = None
) -> str:
token, _ = _create_token(
subject,
timedelta(minutes=settings.access_token_expire_minutes),
expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE,
)
return token
+95
View File
@@ -0,0 +1,95 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
_RELATIONSHIPS = [
"attachment_point_name",
"iso_thread",
"unc_thread",
"clamp_connection",
"static_connection",
]
async def get(db: AsyncSession, attachment_point_id: int) -> AttachmentPoint | None:
return await db.get(AttachmentPoint, attachment_point_id)
async def list_for_augmented_part(
db: AsyncSession, augmented_part_id: int
) -> list[AttachmentPoint]:
result = await db.execute(
select(AttachmentPoint)
.where(AttachmentPoint.augmented_part_id == augmented_part_id)
.order_by(AttachmentPoint.id)
)
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(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPoint]:
result = await db.execute(
select(AttachmentPoint)
.order_by(AttachmentPoint.augmented_part_id, AttachmentPoint.id)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
augmented_part_id: int,
attachment_point_name_id: int,
iso_thread_id: int | None = None,
unc_thread_id: int | None = None,
clamp_connection_id: int | None = None,
static_connection_id: int | None = None,
) -> AttachmentPoint:
ap = AttachmentPoint(
augmented_part_id=augmented_part_id,
attachment_point_name_id=attachment_point_name_id,
iso_thread_id=iso_thread_id,
unc_thread_id=unc_thread_id,
clamp_connection_id=clamp_connection_id,
static_connection_id=static_connection_id,
)
db.add(ap)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def update(db: AsyncSession, ap: AttachmentPoint, data: dict) -> AttachmentPoint:
for field, value in data.items():
setattr(ap, field, value)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def delete(db: AsyncSession, ap: AttachmentPoint) -> None:
await db.delete(ap)
await db.commit()
+50
View File
@@ -0,0 +1,50 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point_name import AttachmentPointName
async def get(db: AsyncSession, apn_id: int) -> AttachmentPointName | None:
return await db.get(AttachmentPointName, apn_id)
async def get_by_name(db: AsyncSession, name: str) -> AttachmentPointName | None:
result = await db.execute(
select(AttachmentPointName).where(AttachmentPointName.name == name)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPointName]:
result = await db.execute(
select(AttachmentPointName)
.order_by(AttachmentPointName.name)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> AttachmentPointName:
apn = AttachmentPointName(name=name)
db.add(apn)
await db.commit()
await db.refresh(apn)
return apn
async def update(
db: AsyncSession, apn: AttachmentPointName, data: dict
) -> AttachmentPointName:
for field, value in data.items():
setattr(apn, field, value)
await db.commit()
await db.refresh(apn)
return apn
async def delete(db: AsyncSession, apn: AttachmentPointName) -> None:
await db.delete(apn)
await db.commit()
+48
View File
@@ -0,0 +1,48 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
from app.models.user import User
def describe_create(resource_type: str, resource_id: int, name: str) -> str:
return f"Created {resource_type} '{name}' (#{resource_id})"
def describe_update(resource_type: str, resource_id: int, changes: dict) -> str:
safe = {k: ("***" if k == "password" else v) for k, v in changes.items()}
parts = ", ".join(f"{k}={v!r}" for k, v in safe.items())
suffix = f": {parts}" if parts else ""
return f"Updated {resource_type} #{resource_id}{suffix}"
def describe_delete(resource_type: str, resource_id: int, name: str) -> str:
return f"Deleted {resource_type} '{name}' (#{resource_id})"
async def log(
db: AsyncSession,
*,
actor: User,
action: str,
resource_type: str,
resource_id: int | None,
description: str,
) -> None:
entry = AuditLog(
user_id=actor.id,
user_email=actor.email,
action=action,
resource_type=resource_type,
resource_id=resource_id,
description=description,
)
db.add(entry)
await db.commit()
async def list_recent(db: AsyncSession, limit: int = 200) -> list[AuditLog]:
result = await db.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
+272
View File
@@ -0,0 +1,272 @@
from sqlalchemy import distinct, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.scope_model import ScopeModel
from app.models.sub_part_type import SubPartType
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
# 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:
return await db.get(AugmentedPart, augmented_part_id)
async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
result = await db.execute(
select(AugmentedPart).where(AugmentedPart.sku == sku).order_by(AugmentedPart.id)
)
return result.scalars().first()
async def list_all(
db: AsyncSession,
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
) -> list[AugmentedPart]:
q = select(AugmentedPart).order_by(AugmentedPart.description)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]:
"""Fetch augmented parts by id in one query (selectin relationships loaded)."""
if not ids:
return []
result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids)))
return list(result.scalars().all())
async def list_filtered(
db: AsyncSession,
description: str = "",
colour_id: int | None = None,
manufacturer_id: int | None = None,
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
sort: str = "description",
direction: str = "asc",
skip: int = 0,
limit: int = 100,
) -> list[AugmentedPart]:
q = select(AugmentedPart)
if description.strip():
term = f"%{description.strip()}%"
q = q.where(
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
)
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
if scope_model_id is not None:
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
if part_type_id is not None:
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
async def count_filtered(
db: AsyncSession,
description: str = "",
colour_id: int | None = None,
manufacturer_id: int | None = None,
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
) -> int:
from sqlalchemy import func
q = select(func.count()).select_from(AugmentedPart)
if description.strip():
term = f"%{description.strip()}%"
q = q.where(
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
)
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
if scope_model_id is not None:
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
if part_type_id is not None:
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q)
return result.scalar_one()
async def get_unlinked_imported_parts_filtered(
db: AsyncSession,
description: str = "",
colour: str = "",
skip: int = 0,
limit: int = 50,
) -> list[ImportedPart]:
q = select(ImportedPart).where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(
q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def count_unlinked_filtered(
db: AsyncSession,
description: str = "",
colour: str = "",
) -> int:
from sqlalchemy import func
q = (
select(func.count())
.select_from(ImportedPart)
.where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(q)
return result.scalar_one()
async def distinct_unlinked_colours(db: AsyncSession) -> list[str]:
q = (
select(distinct(ImportedPart.TBXColour))
.where(
ImportedPart.TBXColour.isnot(None),
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
),
)
.order_by(ImportedPart.TBXColour)
)
result = await db.execute(q)
return [row[0] for row in result.all()]
async def create(
db: AsyncSession,
*,
sku: str | None,
description: str,
colour_id: int,
manufacturer_id: int,
scope_model_id: int | None,
part_type_id: int,
sub_part_type_id: int | None,
) -> AugmentedPart:
ap = AugmentedPart(
sku=sku,
description=description,
colour_id=colour_id,
manufacturer_id=manufacturer_id,
scope_model_id=scope_model_id,
part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id,
)
db.add(ap)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def update(db: AsyncSession, ap: AugmentedPart, data: dict) -> AugmentedPart:
for field, value in data.items():
setattr(ap, field, value)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
"""ImportedParts that have no corresponding AugmentedPart link."""
result = await db.execute(
select(ImportedPart)
.where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
.order_by(ImportedPart.InventoryID)
)
return list(result.scalars().all())
async def set_archived(
db: AsyncSession, ap: AugmentedPart, archived: bool
) -> AugmentedPart:
ap.archived = archived
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
await db.delete(ap)
await db.commit()
+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
+50
View File
@@ -0,0 +1,50 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.clamp_connection import ClampConnection
async def get(db: AsyncSession, clamp_connection_id: int) -> ClampConnection | None:
return await db.get(ClampConnection, clamp_connection_id)
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[ClampConnection]:
result = await db.execute(
select(ClampConnection)
.order_by(
ClampConnection.min_mm.nulls_last(), ClampConnection.min_in.nulls_last()
)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
min_mm: float | None,
max_mm: float | None,
min_in: float | None,
max_in: float | None,
) -> ClampConnection:
cc = ClampConnection(min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in)
db.add(cc)
await db.commit()
await db.refresh(cc)
return cc
async def update(db: AsyncSession, cc: ClampConnection, data: dict) -> ClampConnection:
for field, value in data.items():
setattr(cc, field, value)
await db.commit()
await db.refresh(cc)
return cc
async def delete(db: AsyncSession, cc: ClampConnection) -> None:
await db.delete(cc)
await db.commit()
+121
View File
@@ -0,0 +1,121 @@
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint
from app.models.fit import Fit, FitLabel
# "updated_at" is included because it's a server-side onupdate default: after an
# UPDATE, SQLAlchemy doesn't know its new value unless it's named explicitly here
# too, and access to an unrefreshed attribute would otherwise trigger a lazy load
# outside of an awaited context (MissingGreenlet) during response serialization.
_RELATIONSHIPS = ["attachment_point_a", "attachment_point_b", "notes", "updated_at"]
def _ordered(
ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> tuple[AttachmentPoint, AttachmentPoint]:
"""Canonicalize so attachment_point_a.id < attachment_point_b.id.
Keeps a single row per unordered pair of attachment points.
"""
return (ap_a, ap_b) if ap_a.id < ap_b.id else (ap_b, ap_a)
async def get(db: AsyncSession, fit_id: int) -> Fit | None:
return await db.get(Fit, fit_id)
async def get_by_attachment_points(
db: AsyncSession, ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> Fit | None:
first, second = _ordered(ap_a, ap_b)
result = await db.execute(
select(Fit).where(
Fit.attachment_point_a_id == first.id,
Fit.attachment_point_b_id == second.id,
)
)
return result.scalar_one_or_none()
async def list_for_part(db: AsyncSession, augmented_part_id: int) -> list[Fit]:
result = await db.execute(
select(Fit)
.where(
or_(
Fit.augmented_part_a_id == augmented_part_id,
Fit.augmented_part_b_id == augmented_part_id,
)
)
.order_by(Fit.id)
)
return list(result.scalars().all())
async def list_for_parts(
db: AsyncSession,
part_ids: list[int],
exclude_label: FitLabel | None = None,
) -> list[Fit]:
"""All fits touching any of the given augmented parts, in one query.
Pass ``exclude_label`` to drop, e.g., confirmed non-fits (``FitLabel.NONE``).
"""
if not part_ids:
return []
q = select(Fit).where(
or_(
Fit.augmented_part_a_id.in_(part_ids),
Fit.augmented_part_b_id.in_(part_ids),
)
)
if exclude_label is not None:
q = q.where(Fit.label != exclude_label)
result = await db.execute(q.order_by(Fit.id))
return list(result.scalars().all())
async def list_all(
db: AsyncSession,
skip: int = 0,
limit: int = 100,
label: FitLabel | None = None,
) -> list[Fit]:
q = select(Fit)
if label is not None:
q = q.where(Fit.label == label)
result = await db.execute(q.order_by(Fit.id).offset(skip).limit(limit))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
ap_a: AttachmentPoint,
ap_b: AttachmentPoint,
label: FitLabel,
) -> Fit:
first, second = _ordered(ap_a, ap_b)
fit = Fit(
attachment_point_a_id=first.id,
attachment_point_b_id=second.id,
augmented_part_a_id=first.augmented_part_id,
augmented_part_b_id=second.augmented_part_id,
label=label,
)
db.add(fit)
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def set_label(db: AsyncSession, fit: Fit, label: FitLabel) -> Fit:
fit.label = label
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def delete(db: AsyncSession, fit: Fit) -> None:
await db.delete(fit)
await db.commit()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fit import Fit, FitNote
from app.models.user import User
async def create(db: AsyncSession, *, fit: Fit, author: User, body: str) -> FitNote:
note = FitNote(
fit_id=fit.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
+151
View File
@@ -0,0 +1,151 @@
from sqlalchemy import distinct, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part import ImportedPart
_FIELDS = frozenset(
{
"InventoryID",
"Description",
"TBXColour",
"ProductMaxThreadDepthmm",
"ProductMaxThreadDepthin",
"ProductODmm",
"ProductODin",
"TBXThreadAttribute",
}
)
# 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,
}
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:
return await db.get(ImportedPart, inventory_id)
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 500
) -> list[ImportedPart]:
result = await db.execute(
select(ImportedPart)
.order_by(ImportedPart.InventoryID)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def list_filtered(
db: AsyncSession,
description: str = "",
sort: str = "InventoryID",
direction: str = "asc",
skip: int = 0,
limit: int = 500,
) -> list[ImportedPart]:
q = select(ImportedPart)
if description.strip():
term = f"%{description.strip()}%"
q = q.where(
or_(
ImportedPart.Description.ilike(term),
ImportedPart.InventoryID.ilike(term),
)
)
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
async def distinct_colours(db: AsyncSession) -> list[str]:
result = await db.execute(
select(distinct(ImportedPart.TBXColour))
.where(ImportedPart.TBXColour.isnot(None))
.order_by(ImportedPart.TBXColour)
)
return [row[0] for row in result.all()]
async def count(db: AsyncSession) -> int:
from sqlalchemy import func
result = await db.execute(select(func.count()).select_from(ImportedPart))
return result.scalar_one()
async def count_filtered(db: AsyncSession, description: str = "") -> int:
from sqlalchemy import func
q = select(func.count()).select_from(ImportedPart)
if description.strip():
term = f"%{description.strip()}%"
q = q.where(
or_(
ImportedPart.Description.ilike(term),
ImportedPart.InventoryID.ilike(term),
)
)
result = await db.execute(q)
return result.scalar_one()
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records:
return 0, 0
clean = [
{
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
if r.get("InventoryID")
]
if not clean:
return 0, 0
incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute(
select(ImportedPart.InventoryID).where(
ImportedPart.InventoryID.in_(incoming_ids)
)
)
existing_ids = {row[0] for row in result.all()}
to_insert = [r for r in clean if r["InventoryID"] not in existing_ids]
to_update = [r for r in clean if r["InventoryID"] in existing_ids]
if to_insert:
await db.execute(insert(ImportedPart), to_insert)
for record in to_update:
inv_id = record["InventoryID"]
vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals:
await db.execute(
update(ImportedPart)
.where(ImportedPart.InventoryID == inv_id)
.values(vals)
)
await db.commit()
return len(to_insert), len(to_update)
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part_config import ImportedPartConfig
_SINGLETON_ID = 1
async def get(db: AsyncSession) -> ImportedPartConfig | None:
return await db.get(ImportedPartConfig, _SINGLETON_ID)
async def upsert(
db: AsyncSession,
*,
odata_url: str | None,
username: str | None,
password: str | None,
) -> ImportedPartConfig:
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
if config is None:
config = ImportedPartConfig(
id=_SINGLETON_ID,
odata_url=odata_url,
username=username,
password=password,
)
db.add(config)
else:
config.odata_url = odata_url
config.username = username
config.password = password
await db.commit()
await db.refresh(config)
return config
+25 -1
View File
@@ -8,7 +8,31 @@ async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
return await db.get(IsoThread, iso_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]:
async def get_by_values(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None,
) -> IsoThread | None:
depth_clause = (
IsoThread.depth_mm.is_(None)
if depth_mm is None
else IsoThread.depth_mm == depth_mm
)
result = await db.execute(
select(IsoThread).where(
IsoThread.diameter_mm == diameter_mm,
IsoThread.pitch_mm == pitch_mm,
depth_clause,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[IsoThread]:
result = await db.execute(
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
)
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.manufacturer import Manufacturer
async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None:
return await db.get(Manufacturer, manufacturer_id)
async def get_by_name(db: AsyncSession, name: str) -> Manufacturer | None:
result = await db.execute(select(Manufacturer).where(Manufacturer.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[Manufacturer]:
result = await db.execute(
select(Manufacturer).order_by(Manufacturer.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
url: str,
logo_url: str | None = None,
) -> Manufacturer:
m = Manufacturer(name=name, url=url, logo_url=logo_url)
db.add(m)
await db.commit()
await db.refresh(m)
return m
async def update(
db: AsyncSession, manufacturer: Manufacturer, data: dict
) -> Manufacturer:
for field, value in data.items():
setattr(manufacturer, field, value)
await db.commit()
await db.refresh(manufacturer)
return manufacturer
async def delete(db: AsyncSession, manufacturer: Manufacturer) -> None:
await db.delete(manufacturer)
await db.commit()
+43
View File
@@ -0,0 +1,43 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.part_colour import PartColour
async def get(db: AsyncSession, part_colour_id: int) -> PartColour | None:
return await db.get(PartColour, part_colour_id)
async def get_by_name(db: AsyncSession, name: str) -> PartColour | None:
result = await db.execute(select(PartColour).where(PartColour.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[PartColour]:
result = await db.execute(
select(PartColour).order_by(PartColour.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> PartColour:
pc = PartColour(name=name)
db.add(pc)
await db.commit()
await db.refresh(pc)
return pc
async def update(db: AsyncSession, pc: PartColour, data: dict) -> PartColour:
for field, value in data.items():
setattr(pc, field, value)
await db.commit()
await db.refresh(pc)
return pc
async def delete(db: AsyncSession, pc: PartColour) -> None:
await db.delete(pc)
await db.commit()
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.part_type import PartType
async def get(db: AsyncSession, part_type_id: int) -> PartType | None:
return await db.get(PartType, part_type_id)
async def get_by_name(db: AsyncSession, name: str) -> PartType | None:
result = await db.execute(select(PartType).where(PartType.name == name))
return result.scalar_one_or_none()
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[PartType]:
result = await db.execute(
select(PartType).order_by(PartType.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> PartType:
pt = PartType(name=name)
db.add(pt)
await db.commit()
await db.refresh(pt)
return pt
async def update(db: AsyncSession, part_type: PartType, data: dict) -> PartType:
for field, value in data.items():
setattr(part_type, field, value)
await db.commit()
await db.refresh(part_type)
return part_type
async def delete(db: AsyncSession, part_type: PartType) -> None:
await db.delete(part_type)
await db.commit()
+26
View File
@@ -1,4 +1,5 @@
from sqlalchemy import select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission
@@ -14,6 +15,21 @@ async def get_by_name(db: AsyncSession, name: str) -> Role | None:
return result.scalar_one_or_none()
async def get_default(db: AsyncSession) -> Role | None:
"""The role auto-assigned to new users, or ``None`` if none is marked."""
result = await db.execute(select(Role).where(Role.is_default.is_(True)))
return result.scalars().first()
async def set_default(db: AsyncSession, role: Role) -> Role:
"""Make ``role`` the sole default, clearing the flag on every other role."""
await db.execute(sa_update(Role).where(Role.id != role.id).values(is_default=False))
role.is_default = True
await db.commit()
await db.refresh(role)
return role
async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all())
@@ -50,6 +66,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else:
data.pop("permission_ids", None)
# Setting a role default must clear the flag on all others.
make_default = data.pop("is_default", None)
if make_default:
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
elif make_default is False:
role.is_default = False
for field, value in data.items():
if value is not None:
setattr(role, field, value)
+68
View File
@@ -0,0 +1,68 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scope_info import ScopeInfo
async def get(db: AsyncSession, scope_info_id: int) -> ScopeInfo | None:
return await db.get(ScopeInfo, scope_info_id)
async def get_by_augmented_part(
db: AsyncSession, augmented_part_id: int
) -> ScopeInfo | None:
result = await db.execute(
select(ScopeInfo).where(ScopeInfo.augmented_part_id == augmented_part_id)
)
return result.scalar_one_or_none()
async def list_by_augmented_parts(
db: AsyncSession, augmented_part_ids: list[int]
) -> list[ScopeInfo]:
"""ScopeInfo rows for the given augmented parts, fetched in one query."""
if not augmented_part_ids:
return []
result = await db.execute(
select(ScopeInfo).where(ScopeInfo.augmented_part_id.in_(augmented_part_ids))
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
augmented_part_id: int,
magnification_min: float | None = None,
magnification_max: float | None = None,
model_suffix: str | None = None,
objective_lens_diameter_mm: float | None = None,
) -> ScopeInfo:
info = ScopeInfo(
augmented_part_id=augmented_part_id,
magnification_min=magnification_min,
magnification_max=magnification_max,
model_suffix=model_suffix,
objective_lens_diameter_mm=objective_lens_diameter_mm,
)
db.add(info)
await db.commit()
await db.refresh(info)
return info
async def upsert(db: AsyncSession, *, augmented_part_id: int, **fields) -> ScopeInfo:
"""Create the ScopeInfo for an AugmentedPart, or update it if it exists."""
info = await get_by_augmented_part(db, augmented_part_id)
if info is None:
return await create(db, augmented_part_id=augmented_part_id, **fields)
for field, value in fields.items():
setattr(info, field, value)
await db.commit()
await db.refresh(info)
return info
async def delete(db: AsyncSession, info: ScopeInfo) -> None:
await db.delete(info)
await db.commit()
+56
View File
@@ -0,0 +1,56 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scope_model import ScopeModel
async def get(db: AsyncSession, scope_model_id: int) -> ScopeModel | None:
return await db.get(ScopeModel, scope_model_id)
async def get_by_name_and_manufacturer(
db: AsyncSession, name: str, manufacturer_id: int
) -> ScopeModel | None:
result = await db.execute(
select(ScopeModel).where(
ScopeModel.name == name,
ScopeModel.manufacturer_id == manufacturer_id,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[ScopeModel]:
result = await db.execute(
select(ScopeModel).order_by(ScopeModel.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
manufacturer_id: int,
url: str | None = None,
) -> ScopeModel:
sm = ScopeModel(name=name, manufacturer_id=manufacturer_id, url=url)
db.add(sm)
await db.commit()
await db.refresh(sm, attribute_names=["manufacturer"])
return sm
async def update(db: AsyncSession, scope_model: ScopeModel, data: dict) -> ScopeModel:
for field, value in data.items():
setattr(scope_model, field, value)
await db.commit()
await db.refresh(scope_model, attribute_names=["manufacturer"])
return scope_model
async def delete(db: AsyncSession, scope_model: ScopeModel) -> None:
await db.delete(scope_model)
await db.commit()
+55
View File
@@ -0,0 +1,55 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.static_connection import StaticConnection
async def get(db: AsyncSession, static_connection_id: int) -> StaticConnection | None:
return await db.get(StaticConnection, static_connection_id)
async def get_by_od_mm(db: AsyncSession, od_mm: float) -> StaticConnection | None:
result = await db.execute(
select(StaticConnection).where(StaticConnection.od_mm == od_mm)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[StaticConnection]:
result = await db.execute(
select(StaticConnection)
.order_by(StaticConnection.od_mm)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
od_mm: float,
od_in: float,
) -> StaticConnection:
sc = StaticConnection(od_mm=od_mm, od_in=od_in)
db.add(sc)
await db.commit()
await db.refresh(sc)
return sc
async def update(
db: AsyncSession, sc: StaticConnection, data: dict
) -> StaticConnection:
for field, value in data.items():
setattr(sc, field, value)
await db.commit()
await db.refresh(sc)
return sc
async def delete(db: AsyncSession, sc: StaticConnection) -> None:
await db.delete(sc)
await db.commit()
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sub_part_type import SubPartType
async def get(db: AsyncSession, sub_part_type_id: int) -> SubPartType | None:
return await db.get(SubPartType, sub_part_type_id)
async def get_by_name_and_part_type(
db: AsyncSession, name: str, part_type_id: int
) -> SubPartType | None:
result = await db.execute(
select(SubPartType).where(
SubPartType.name == name, SubPartType.part_type_id == part_type_id
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[SubPartType]:
result = await db.execute(
select(SubPartType).order_by(SubPartType.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str, part_type_id: int) -> SubPartType:
spt = SubPartType(name=name, part_type_id=part_type_id)
db.add(spt)
await db.commit()
await db.refresh(spt, attribute_names=["part_type"])
return spt
async def update(
db: AsyncSession, sub_part_type: SubPartType, data: dict
) -> SubPartType:
for field, value in data.items():
setattr(sub_part_type, field, value)
await db.commit()
await db.refresh(sub_part_type, attribute_names=["part_type"])
return sub_part_type
async def delete(db: AsyncSession, sub_part_type: SubPartType) -> None:
await db.delete(sub_part_type)
await db.commit()
+27 -1
View File
@@ -8,7 +8,33 @@ async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
return await db.get(UncThread, unc_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]:
async def get_by_values(
db: AsyncSession,
*,
diameter_fraction: str,
tpi: int,
series: UncSeries,
depth_in: float | None,
) -> UncThread | None:
depth_clause = (
UncThread.depth_in.is_(None)
if depth_in is None
else UncThread.depth_in == depth_in
)
result = await db.execute(
select(UncThread).where(
UncThread.diameter_fraction == diameter_fraction,
UncThread.tpi == tpi,
UncThread.series == series,
depth_clause,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[UncThread]:
result = await db.execute(
select(UncThread).order_by(UncThread.id).offset(skip).limit(limit)
)
+47 -4
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.role import Role
from app.models.user import User
from app.models.user import AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User
async def get(db: AsyncSession, user_id: int) -> User | None:
@@ -15,6 +15,11 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
return result.scalar_one_or_none()
async def get_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all())
@@ -36,14 +41,18 @@ async def create(
db: AsyncSession,
*,
email: str,
password: str,
password: str | None = None,
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
username: str | None = None,
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
username=username,
hashed_password=hash_password(password) if password else None,
auth_source=auth_source,
full_name=full_name,
is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []),
@@ -54,6 +63,37 @@ async def create(
return user
async def get_or_create_ldap_user(
db: AsyncSession,
*,
username: str,
email: str,
full_name: str | None,
role_ids: list[int] | None = None,
) -> User:
"""Find an LDAP user by username, provisioning a local profile on first login.
The profile is keyed on the LDAP username; email is synthesized by the
caller. Newly provisioned users get ``role_ids`` (the default role).
"""
user = await get_by_username(db, username)
if user is not None:
# Keep the display name in sync with LDAP on subsequent logins.
if full_name and user.full_name != full_name:
user.full_name = full_name
await db.commit()
await db.refresh(user)
return user
return await create(
db,
email=email,
username=username,
full_name=full_name,
auth_source=AUTH_SOURCE_LDAP,
role_ids=role_ids,
)
async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password"))
@@ -81,6 +121,9 @@ async def delete(db: AsyncSession, user: User) -> None:
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if not user or not verify_password(password, user.hashed_password):
# LDAP users have no local password and must sign in via LDAP.
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None
return user
+12 -1
View File
@@ -1,5 +1,6 @@
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -12,6 +13,7 @@ from app.core.database import init_db
from app.deps import NotAuthenticated
from app.web.admin import router as admin_router
from app.web.pages import router as pages_router
from app.web.scopes import router as scopes_router
STATIC_DIR = Path(__file__).resolve().parent / "static"
@@ -39,11 +41,20 @@ def create_app() -> FastAPI:
app.include_router(api_router, prefix="/api")
app.include_router(pages_router)
app.include_router(scopes_router)
app.include_router(admin_router)
@app.exception_handler(NotAuthenticated)
async def _redirect_to_login(request: Request, exc: NotAuthenticated):
return RedirectResponse(url="/login", status_code=302)
# Preserve the originally requested URL so we can return there after
# login. Only meaningful for GET navigations (the ones users deep-link).
login_url = "/login"
if request.method == "GET" and request.url.path != "/login":
target = request.url.path
if request.url.query:
target += "?" + request.url.query
login_url = f"/login?next={quote(target, safe='')}"
return RedirectResponse(url=login_url, status_code=302)
@app.get("/healthz", tags=["health"])
async def healthz():
+41 -1
View File
@@ -1,20 +1,60 @@
"""Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles
from app.models.associations import (
augmented_part_imported_parts,
role_permissions,
user_roles,
)
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.job import Job
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.permission import Permission
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.unc_thread import UncThread
from app.models.user import User
__all__ = [
"AttachmentPoint",
"AttachmentPointName",
"AuditLog",
"AugmentedPart",
"AugmentedPartNote",
"ClampConnection",
"Fit",
"FitLabel",
"FitNote",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Job",
"Manufacturer",
"PartColour",
"PartType",
"Permission",
"ScopeInfo",
"ScopeModel",
"StaticConnection",
"SubPartType",
"RefreshToken",
"Role",
"UncThread",
"User",
"augmented_part_imported_parts",
"role_permissions",
"user_roles",
]
+18 -2
View File
@@ -1,6 +1,6 @@
"""Many-to-many association tables for RBAC."""
"""Many-to-many association tables."""
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy import Column, ForeignKey, String, Table
from app.core.database import Base
@@ -21,3 +21,19 @@ role_permissions = Table(
primary_key=True,
),
)
augmented_part_imported_parts = Table(
"augmented_part_imported_parts",
Base.metadata,
Column(
"augmented_part_id",
ForeignKey("augmented_parts.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"imported_part_id",
String,
ForeignKey("imported_parts.InventoryID", ondelete="CASCADE"),
primary_key=True,
),
)
+49
View File
@@ -0,0 +1,49 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.attachment_point_name import AttachmentPointName
from app.models.clamp_connection import ClampConnection
from app.models.iso_thread import IsoThread
from app.models.static_connection import StaticConnection
from app.models.unc_thread import UncThread
class AttachmentPoint(Base):
__tablename__ = "attachment_points"
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
attachment_point_name_id: Mapped[int] = mapped_column(
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
)
# At most one of the four below is non-null (enforced in application layer).
# All four null means a "Manual" attachment point: no connection details,
# used to let a user assert a Fit by hand without the fit-matching algorithm
# (app.services.fit_matching) ever auto-matching it.
iso_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
)
unc_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("unc_threads.id", ondelete="RESTRICT"), nullable=True, default=None
)
clamp_connection_id: Mapped[int | None] = mapped_column(
ForeignKey("clamp_connections.id", ondelete="RESTRICT"),
nullable=True,
default=None,
)
static_connection_id: Mapped[int | None] = mapped_column(
ForeignKey("static_connections.id", ondelete="RESTRICT"),
nullable=True,
default=None,
)
attachment_point_name: Mapped[AttachmentPointName] = relationship(lazy="selectin")
iso_thread: Mapped[IsoThread | None] = relationship(lazy="selectin")
unc_thread: Mapped[UncThread | None] = relationship(lazy="selectin")
clamp_connection: Mapped[ClampConnection | None] = relationship(lazy="selectin")
static_connection: Mapped[StaticConnection | None] = relationship(lazy="selectin")
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AttachmentPointName(Base):
__tablename__ = "attachment_point_names"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
+23
View File
@@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
user_email: Mapped[str] = mapped_column(String(255), nullable=False)
action: Mapped[str] = mapped_column(String(16), nullable=False)
resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
resource_id: Mapped[int | None] = mapped_column(nullable=True)
description: Mapped[str] = mapped_column(nullable=False)
+80
View File
@@ -0,0 +1,80 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import augmented_part_imported_parts
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
class AugmentedPart(Base):
__tablename__ = "augmented_parts"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
description: Mapped[str] = mapped_column(String, nullable=False)
colour_id: Mapped[int] = mapped_column(
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
)
manufacturer_id: Mapped[int] = mapped_column(
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
)
scope_model_id: Mapped[int | None] = mapped_column(
ForeignKey("scope_models.id", ondelete="SET NULL"), nullable=True, default=None
)
part_type_id: Mapped[int] = mapped_column(
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
)
sub_part_type_id: Mapped[int | None] = mapped_column(
ForeignKey("sub_part_types.id", ondelete="SET NULL"),
nullable=True,
default=None,
)
# Set instead of deleting the row when the last backing ImportedPart is
# removed, so fit/attachment-point history is preserved. Hidden from list
# views by default.
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# linked imported parts — not eagerly loaded; used by sync service only
imported_parts: Mapped[list[ImportedPart]] = relationship(
secondary=augmented_part_imported_parts, lazy="select"
)
colour: Mapped[PartColour] = relationship(lazy="selectin")
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
part_type: Mapped[PartType] = 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")
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ClampConnection(Base):
__tablename__ = "clamp_connections"
id: Mapped[int] = mapped_column(primary_key=True)
min_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
min_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
+89
View File
@@ -0,0 +1,89 @@
import enum
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class FitLabel(enum.StrEnum):
POSSIBLE = "possible"
EXACT = "exact"
NONE = "none"
class Fit(Base):
"""A candidate or confirmed fit between two attachment points on different parts."""
__tablename__ = "fits"
__table_args__ = (
UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
# Canonical ordering (attachment_point_a_id < attachment_point_b_id) is enforced
# by app.crud.fit so an unordered pair only ever has one row.
attachment_point_a_id: Mapped[int] = mapped_column(
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
)
attachment_point_b_id: Mapped[int] = mapped_column(
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
)
# Denormalized from the attachment points' augmented_part_id, for querying
# "fits for this part" without joining through attachment_points.
augmented_part_a_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
augmented_part_b_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[FitLabel] = mapped_column(
Enum(FitLabel, values_callable=lambda enum_cls: [e.value for e in enum_cls]),
nullable=False,
default=FitLabel.POSSIBLE,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
attachment_point_a = relationship(
"AttachmentPoint", foreign_keys=[attachment_point_a_id], lazy="selectin"
)
attachment_point_b = relationship(
"AttachmentPoint", foreign_keys=[attachment_point_b_id], lazy="selectin"
)
notes: Mapped[list["FitNote"]] = relationship(
back_populates="fit",
lazy="selectin",
order_by="FitNote.created_at",
cascade="all, delete-orphan",
)
class FitNote(Base):
__tablename__ = "fit_notes"
id: Mapped[int] = mapped_column(primary_key=True)
fit_id: Mapped[int] = mapped_column(
ForeignKey("fits.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 AuditLog.user_email),
# 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()
)
fit: Mapped[Fit] = relationship(back_populates="notes")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPart(Base):
__tablename__ = "imported_parts"
InventoryID: Mapped[str] = mapped_column(String, primary_key=True)
Description: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXColour: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductMaxThreadDepthmm: Mapped[str | None] = mapped_column(
nullable=True, default=None
)
ProductMaxThreadDepthin: 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)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPartConfig(Base):
"""Singleton (id=1) storing the OData source URL and credentials."""
__tablename__ = "imported_part_config"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
odata_url: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
username: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
password: Mapped[str | None] = mapped_column(String, 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
)
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Manufacturer(Base):
__tablename__ = "manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=False)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PartColour(Base):
__tablename__ = "part_colours"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PartType(Base):
__tablename__ = "part_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -11,6 +11,9 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
# The role auto-assigned to newly provisioned/registered users. At most one
# role is the default; see ``crud.role.set_default``.
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles,
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy import Float, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ScopeInfo(Base):
"""Scope-specific attributes for an AugmentedPart of PartType "Scope".
One-to-one with AugmentedPart (unique augmented_part_id). Holds fields that
have no home on AugmentedPart itself; populated by the scope xlsx importer.
"""
__tablename__ = "scope_infos"
__table_args__ = (UniqueConstraint("augmented_part_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
magnification_min: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
magnification_max: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
model_suffix: Mapped[str | None] = mapped_column(
String, nullable=True, default=None
)
# The lens diameter is a descriptive attribute (the "50" in "3-18x50"), not a
# connection dimension — it has no AttachmentPoint.
objective_lens_diameter_mm: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.manufacturer import Manufacturer
class ScopeModel(Base):
__tablename__ = "scope_models"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
manufacturer_id: Mapped[int] = mapped_column(
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
)
url: Mapped[str | None] = mapped_column(nullable=True, default=None)
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Float, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class StaticConnection(Base):
__tablename__ = "static_connections"
__table_args__ = (UniqueConstraint("od_mm"),)
id: Mapped[int] = mapped_column(primary_key=True)
od_mm: Mapped[float] = mapped_column(Float, nullable=False)
od_in: Mapped[float] = mapped_column(Float, nullable=False)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.part_type import PartType
class SubPartType(Base):
__tablename__ = "sub_part_types"
__table_args__ = (UniqueConstraint("name", "part_type_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
part_type_id: Mapped[int] = mapped_column(
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
)
part_type: Mapped[PartType] = relationship(lazy="selectin")
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UncSeries(str, enum.Enum):
class UncSeries(enum.StrEnum):
UNC = "UNC"
UNF = "UNF"
UNEF = "UNEF"
+11 -1
View File
@@ -6,13 +6,23 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import user_roles
# Where a user's credentials are verified.
AUTH_SOURCE_LOCAL = "local"
AUTH_SOURCE_LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
# Set for users provisioned from LDAP; how they sign in. Null for local users.
username: Mapped[str | None] = mapped_column(
String(255), unique=True, index=True, default=None
)
# Null for LDAP users, who have no local password (verified against LDAP).
hashed_password: Mapped[str | None] = mapped_column(String(255), default=None)
auth_source: Mapped[str] = mapped_column(String(16), default=AUTH_SOURCE_LOCAL)
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.attachment_point_name import AttachmentPointNameRead
from app.schemas.clamp_connection import ClampConnectionRead
from app.schemas.iso_thread import IsoThreadRead
from app.schemas.static_connection import StaticConnectionRead
from app.schemas.unc_thread import UncThreadRead
class AttachmentPointBase(BaseModel):
augmented_part_id: int
attachment_point_name_id: int
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointCreate(AttachmentPointBase):
pass
class AttachmentPointUpdate(BaseModel):
augmented_part_id: int | None = None
attachment_point_name_id: int | None = None
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointRead(AttachmentPointBase):
model_config = ConfigDict(from_attributes=True)
id: int
attachment_point_name: AttachmentPointNameRead
iso_thread: IsoThreadRead | None = None
unc_thread: UncThreadRead | None = None
clamp_connection: ClampConnectionRead | None = None
static_connection: StaticConnectionRead | None = None
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, ConfigDict
class AttachmentPointNameBase(BaseModel):
name: str
class AttachmentPointNameCreate(AttachmentPointNameBase):
pass
class AttachmentPointNameUpdate(BaseModel):
name: str | None = None
class AttachmentPointNameRead(AttachmentPointNameBase):
model_config = ConfigDict(from_attributes=True)
id: int
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
user_id: int | None
user_email: str
action: str
resource_type: str
resource_id: int | None
description: str
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more