Compare commits

...
100 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
Ian Roddis 9111117404 Adding mise 2026-06-25 10:07:02 -03:00
Ian Roddis 78efe32b69 Adding ACL Modificationf for existing roles 2026-06-24 22:01:25 -03:00
Ian Roddis 35e8a61b1d Adding ISOThread and UNC Thread 2026-06-24 21:58:15 -03:00
Ian Roddis 9285a021c0 adjust lock file 2026-06-24 21:37:14 -03:00
Ian Roddis a751e9b680 adding parts 2026-06-22 21:47:18 -03:00
Ian Roddis a3f686ab3b Baseline for fitguide 2026-06-22 21:32:22 -03:00
242 changed files with 18236 additions and 170 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
+19 -1
View File
@@ -2,7 +2,7 @@
# environment variable (which takes precedence over the .env file). # environment variable (which takes precedence over the .env file).
# --- General --- # --- General ---
APP_NAME="FastAPI Skeleton" APP_NAME="Armament FitGuide"
DEBUG=false DEBUG=false
# --- Security --- # --- Security ---
@@ -25,6 +25,24 @@ AUTO_CREATE_TABLES=true
COOKIE_NAME=access_token COOKIE_NAME=access_token
# Set true when serving over HTTPS. # Set true when serving over HTTPS.
COOKIE_SECURE=false COOKIE_SECURE=false
# How long a browser login lasts before being bounced back to /login.
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
SESSION_TIMEOUT_HOURS=24
# --- LDAP (optional; direct-bind) ---
# 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 (JSON list) ---
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
+2
View File
@@ -10,6 +10,8 @@ venv/
# Databases # Databases
*.db *.db
*.db-shm
*.db-wal
*.sqlite3 *.sqlite3
# Test / tooling # Test / tooling
+94 -13
View File
@@ -1,26 +1,41 @@
# CLAUDE.md # 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 ## What this is
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered **Fitguide**: a parts-compatibility guide for Armament scopes and accessories,
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth built on a FastAPI skeleton that serves **both** a JSON API (`/api/*`) and
with refresh-token revocation, and RBAC (users → roles → permissions). 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 ## Commands
```bash ```bash
uv sync --extra dev # install (incl. dev tools) 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 python -m scripts.seed # seed perms/roles/superuser (idempotent)
uv run uvicorn app.main:app --reload # dev server on :8000 uv run uvicorn app.main:app --reload # dev server on :8000
uv run pytest # tests (in-memory SQLite) 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 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 uv run alembic revision --autogenerate -m "msg" # new migration after model changes
docker compose up --build # app + Postgres, auto-seeded 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 ## Architecture / layering
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access) 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 token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
`crud/`, not in routers. `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 ## Conventions that matter
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never - **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. 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 - **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`,
attributes are safe to read after the session closes — required under async. `AttachmentPoint.iso_thread`, etc.) so attributes are safe to read after the
Preserve this when adding relationships that templates/schemas read. 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; - **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. they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`; - **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
all checks (`User.is_superuser` / `User.has_permission`). Add new permission all checks (`User.is_superuser` / `User.has_permission`). Add new permission
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`. 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 - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth 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** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. `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 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. 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 ## Database
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all` (`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
on startup for dev convenience; production should use Alembic instead. `init_db()` on startup for dev convenience, but the day-to-day `make seed`/`make clean` flow
imports `app.models` inside the function to avoid a circular import. 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
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates `tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded` tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*` (perms+roles from `scripts.seed.PERMISSIONS`/`ROLES`), `make_user`, and
without decorators. `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 ## When extending
+5 -2
View File
@@ -18,8 +18,11 @@ RUN uv sync --no-dev
# Copy the application. # Copy the application.
COPY . . COPY . .
RUN chmod +x entrypoint.sh
# If sqlite3 is used
VOLUME /data
EXPOSE 8000 EXPOSE 8000
# Default: run the API server. docker-compose overrides this to also seed. # Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server.
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ENTRYPOINT ["./entrypoint.sh"]
+24
View File
@@ -0,0 +1,24 @@
- Parts
- Type
- Scope
- Adapter Ring
- ARD
- Polarizer
- Flip Cover
- Attachment Point +
- Name
- Attachment
- One Of
- ISO Thread
- UNC Thread
- Outside Diameter
- static value (to be attached to)
- Clamp
- min
- max
- Manufacturers
- Models
- Colours
- Part Types
- Notes
+21 -1
View File
@@ -1,10 +1,14 @@
VERSION := $(shell git tag | tail -1)
IMAGE_NAME := fitguide
sync: sync:
uv sync --extra dev uv sync --extra dev
seed: seed:
uv run alembic upgrade head
uv run python -m scripts.seed uv run python -m scripts.seed
dev: dev: migrate
uv run uvicorn app.main:app --reload uv run uvicorn app.main:app --reload
open http://localhost:8000 open http://localhost:8000
@@ -17,3 +21,19 @@ migrate:
fmt: fmt:
uv run black . 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** | | `SECRET_KEY` | dev placeholder | **set a strong value in production** |
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres | | `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | | | `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
@@ -0,0 +1,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")
@@ -0,0 +1,41 @@
"""add iso_threads and unc_threads tables
Revision ID: 627d4460a94f
Revises: 0001_initial
Create Date: 2026-06-24 21:50:05.340235
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "627d4460a94f"
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"iso_threads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("diameter_mm", sa.Float(), nullable=False),
sa.Column("pitch_mm", sa.Float(), nullable=False),
sa.Column("depth_mm", sa.Float(), nullable=True),
)
op.create_table(
"unc_threads",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("diameter_fraction", sa.String(length=16), nullable=False),
sa.Column("tpi", sa.Integer(), nullable=False),
sa.Column("series", sa.String(length=8), nullable=False),
sa.Column("depth_in", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_table("unc_threads")
op.drop_table("iso_threads")
@@ -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 fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import role as role_crud from app.crud import role as role_crud
from app.crud import token as token_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.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead 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 from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter() router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user" 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) @router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email): if await user_crud.get_by_email(db, payload.email):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered" 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( user = await user_crud.create(
db, db,
email=payload.email, email=payload.email,
password=payload.password, password=payload.password,
full_name=payload.full_name, full_name=payload.full_name,
role_ids=role_ids, role_ids=await _default_role_ids(db),
) )
return user return user
@@ -52,6 +60,27 @@ async def login(
return Token(access_token=access, refresh_token=refresh) 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) @router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)): async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try: 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}"
),
)
+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 iso_thread as iso_thread_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.iso_thread import IsoThreadCreate, IsoThreadRead, IsoThreadUpdate
router = APIRouter()
@router.get("", response_model=list[IsoThreadRead])
async def list_iso_threads(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
return await iso_thread_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=IsoThreadRead, status_code=status.HTTP_201_CREATED)
async def create_iso_thread(
payload: IsoThreadCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("iso_threads:write")),
):
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)
async def get_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
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 thread
@router.patch("/{iso_thread_id}", response_model=IsoThreadRead)
async def update_iso_thread(
iso_thread_id: int,
payload: IsoThreadUpdate,
db: AsyncSession = Depends(get_db),
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"
)
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),
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"
)
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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import role as role_crud from app.crud import role as role_crud
from app.deps import require_permission from app.deps import require_permission
from app.models.user import User from app.models.user import User
@@ -22,18 +23,27 @@ async def list_roles(
async def create_role( async def create_role(
payload: RoleCreate, payload: RoleCreate,
db: AsyncSession = Depends(get_db), 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): if await role_crud.get_by_name(db, payload.name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists" status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
) )
return await role_crud.create( role = await role_crud.create(
db, db,
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
permission_ids=payload.permission_ids, 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]) @router.get("/permissions", response_model=list[PermissionRead])
@@ -63,25 +73,44 @@ async def update_role(
role_id: int, role_id: int,
payload: RoleUpdate, payload: RoleUpdate,
db: AsyncSession = Depends(get_db), 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) role = await role_crud.get(db, role_id)
if role is None: if role is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" 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) @router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role( async def delete_role(
role_id: int, role_id: int,
db: AsyncSession = Depends(get_db), 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) role = await role_crud.get(db, role_id)
if role is None: if role is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
) )
name = role.name
await role_crud.delete(db, role) 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 -1
View File
@@ -1,8 +1,72 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import auth, roles, 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) 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(
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),
)
+111
View File
@@ -0,0 +1,111 @@
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
from app.schemas.unc_thread import UncThreadCreate, UncThreadRead, UncThreadUpdate
router = APIRouter()
@router.get("", response_model=list[UncThreadRead])
async def list_unc_threads(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:read")),
):
return await unc_thread_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=UncThreadRead, status_code=status.HTTP_201_CREATED)
async def create_unc_thread(
payload: UncThreadCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("unc_threads:write")),
):
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)
async def get_unc_thread(
unc_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:read")),
):
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 thread
@router.patch("/{unc_thread_id}", response_model=UncThreadRead)
async def update_unc_thread(
unc_thread_id: int,
payload: UncThreadUpdate,
db: AsyncSession = Depends(get_db),
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"
)
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),
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"
)
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 sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import user as user_crud from app.crud import user as user_crud
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.models.user import User from app.models.user import User
@@ -29,13 +30,13 @@ async def list_users(
async def create_user( async def create_user(
payload: UserCreate, payload: UserCreate,
db: AsyncSession = Depends(get_db), 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): if await user_crud.get_by_email(db, payload.email):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered" status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
) )
return await user_crud.create( created = await user_crud.create(
db, db,
email=payload.email, email=payload.email,
password=payload.password, password=payload.password,
@@ -43,6 +44,15 @@ async def create_user(
is_superuser=payload.is_superuser, is_superuser=payload.is_superuser,
role_ids=payload.role_ids, 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) @router.get("/{user_id}", response_model=UserRead)
@@ -51,12 +61,12 @@ async def get_user(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")), _: User = Depends(require_permission("users:read")),
): ):
user = await user_crud.get(db, user_id) target = await user_crud.get(db, user_id)
if user is None: if target is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) )
return user return target
@router.patch("/{user_id}", response_model=UserRead) @router.patch("/{user_id}", response_model=UserRead)
@@ -64,25 +74,44 @@ async def update_user(
user_id: int, user_id: int,
payload: UserUpdate, payload: UserUpdate,
db: AsyncSession = Depends(get_db), 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) target = await user_crud.get(db, user_id)
if user is None: if target is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" 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) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
user_id: int, user_id: int,
db: AsyncSession = Depends(get_db), 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) target = await user_crud.get(db, user_id)
if user is None: if target is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" 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" database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie) # Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
# session_timeout_hours governs how long a browser login lasts before the
# user is bounced back to /login; it's independent of the JSON API's
# short-lived access_token_expire_minutes.
cookie_name: str = "access_token" cookie_name: str = "access_token"
cookie_secure: bool = False cookie_secure: bool = False
session_timeout_hours: int = 24
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# 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
cors_origins: list[str] = ["*"] 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 collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -21,6 +22,20 @@ class Base(DeclarativeBase):
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True) 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( AsyncSessionLocal = async_sessionmaker(
engine, engine,
class_=AsyncSession, class_=AsyncSession,
+4 -2
View File
@@ -47,10 +47,12 @@ def _create_token(
return token, payload return token, payload
def create_access_token(subject: str | int) -> str: def create_access_token(
subject: str | int, expires_delta: timedelta | None = None
) -> str:
token, _ = _create_token( token, _ = _create_token(
subject, subject,
timedelta(minutes=settings.access_token_expire_minutes), expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE, ACCESS_TOKEN_TYPE,
) )
return token return token
+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
+66
View File
@@ -0,0 +1,66 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.iso_thread import IsoThread
async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
return await db.get(IsoThread, iso_thread_id)
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)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None = None,
) -> IsoThread:
thread = IsoThread(diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=depth_mm)
db.add(thread)
await db.commit()
await db.refresh(thread)
return thread
async def update(db: AsyncSession, thread: IsoThread, data: dict) -> IsoThread:
for field, value in data.items():
setattr(thread, field, value)
await db.commit()
await db.refresh(thread)
return thread
async def delete(db: AsyncSession, thread: IsoThread) -> None:
await db.delete(thread)
await db.commit()
+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 select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission 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() 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]: async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id)) result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -50,6 +66,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else: else:
data.pop("permission_ids", None) 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(): for field, value in data.items():
if value is not None: if value is not None:
setattr(role, field, value) 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()
+74
View File
@@ -0,0 +1,74 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.unc_thread import UncSeries, UncThread
async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
return await db.get(UncThread, unc_thread_id)
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)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
diameter_fraction: str,
tpi: int,
series: UncSeries,
depth_in: float | None = None,
) -> UncThread:
thread = UncThread(
diameter_fraction=diameter_fraction,
tpi=tpi,
series=series,
depth_in=depth_in,
)
db.add(thread)
await db.commit()
await db.refresh(thread)
return thread
async def update(db: AsyncSession, thread: UncThread, data: dict) -> UncThread:
for field, value in data.items():
setattr(thread, field, value)
await db.commit()
await db.refresh(thread)
return thread
async def delete(db: AsyncSession, thread: UncThread) -> None:
await db.delete(thread)
await db.commit()
+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.core.security import hash_password, verify_password
from app.models.role import Role 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: 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() 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]: 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)) result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -36,14 +41,18 @@ async def create(
db: AsyncSession, db: AsyncSession,
*, *,
email: str, email: str,
password: str, password: str | None = None,
full_name: str | None = None, full_name: str | None = None,
is_superuser: bool = False, is_superuser: bool = False,
role_ids: list[int] | None = None, role_ids: list[int] | None = None,
username: str | None = None,
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User: ) -> User:
user = User( user = User(
email=email, 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, full_name=full_name,
is_superuser=is_superuser, is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []), roles=await _resolve_roles(db, role_ids or []),
@@ -54,6 +63,37 @@ async def create(
return user 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: async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]: if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("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: async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email) 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 None
return user return user
+12 -1
View File
@@ -1,5 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -12,6 +13,7 @@ from app.core.database import init_db
from app.deps import NotAuthenticated from app.deps import NotAuthenticated
from app.web.admin import router as admin_router from app.web.admin import router as admin_router
from app.web.pages import router as pages_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" 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(api_router, prefix="/api")
app.include_router(pages_router) app.include_router(pages_router)
app.include_router(scopes_router)
app.include_router(admin_router) app.include_router(admin_router)
@app.exception_handler(NotAuthenticated) @app.exception_handler(NotAuthenticated)
async def _redirect_to_login(request: Request, exc: 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"]) @app.get("/healthz", tags=["health"])
async def healthz(): async def healthz():
+45 -1
View File
@@ -1,16 +1,60 @@
"""Import all models so they register with ``Base.metadata``.""" """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.permission import Permission
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken
from app.models.role import Role 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 from app.models.user import User
__all__ = [ __all__ = [
"AttachmentPoint",
"AttachmentPointName",
"AuditLog",
"AugmentedPart",
"AugmentedPartNote",
"ClampConnection",
"Fit",
"FitLabel",
"FitNote",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Job",
"Manufacturer",
"PartColour",
"PartType",
"Permission", "Permission",
"ScopeInfo",
"ScopeModel",
"StaticConnection",
"SubPartType",
"RefreshToken", "RefreshToken",
"Role", "Role",
"UncThread",
"User", "User",
"augmented_part_imported_parts",
"role_permissions", "role_permissions",
"user_roles", "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 from app.core.database import Base
@@ -21,3 +21,19 @@ role_permissions = Table(
primary_key=True, 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)
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class IsoThread(Base):
__tablename__ = "iso_threads"
id: Mapped[int] = mapped_column(primary_key=True)
diameter_mm: Mapped[float] = mapped_column(Float, nullable=False)
pitch_mm: Mapped[float] = mapped_column(Float, nullable=False)
depth_mm: Mapped[float | None] = mapped_column(Float, 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -11,6 +11,9 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None) 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 users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles, 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")
+24
View File
@@ -0,0 +1,24 @@
import enum
from sqlalchemy import Enum, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UncSeries(enum.StrEnum):
UNC = "UNC"
UNF = "UNF"
UNEF = "UNEF"
UN = "UN"
UNS = "UNS"
class UncThread(Base):
__tablename__ = "unc_threads"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
diameter_fraction: Mapped[str] = mapped_column(String(16), nullable=False)
tpi: Mapped[int] = mapped_column(Integer, nullable=False)
series: Mapped[UncSeries] = mapped_column(Enum(UncSeries), nullable=False)
depth_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
+11 -1
View File
@@ -6,13 +6,23 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
from app.models.associations import user_roles 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=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) full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) 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
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more