Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebd0f4498 | ||
|
|
d54df47a2c | ||
|
|
12e6984c63 | ||
|
|
fcb4dbaa62 | ||
|
|
a617b935d3 | ||
|
|
d108cb7149 | ||
|
|
373a2c6acf | ||
|
|
2fb662080b | ||
|
|
34679d53e8 | ||
|
|
30a13644ce | ||
|
|
23d07505bd | ||
|
|
92ca6b2a9f | ||
|
|
b2d01f1ab0 | ||
|
|
e24b8e5ea6 | ||
|
|
aef60d77be | ||
|
|
25fccd617f | ||
|
|
a7fb902611 | ||
|
|
1b52ad04bc | ||
|
|
2587ccbceb | ||
|
|
3febc15a8f | ||
|
|
bc8b67d585 | ||
|
|
16174fe4cb | ||
|
|
b5529e0dc5 | ||
|
|
b6dc6dd6b8 | ||
|
|
aca15db9bf | ||
|
|
a0bd7f2c27 | ||
|
|
0150c19b75 | ||
|
|
1180694738 | ||
|
|
6d20db75c5 | ||
|
|
69cbfa2d3f | ||
|
|
cda15df2ed | ||
|
|
ab39191af5 | ||
|
|
8185f1f8b3 | ||
|
|
7bd07f084a | ||
|
|
87ba8cd169 | ||
|
|
c5d1186b95 | ||
|
|
4785fd5f87 | ||
|
|
e923250177 | ||
|
|
a50fccd56d | ||
|
|
d67b4ceb65 | ||
|
|
ceae27a12e | ||
|
|
2767fb1c8e | ||
|
|
ae50ec7049 | ||
|
|
7fcf5b3f75 | ||
|
|
59184124e7 | ||
|
|
0168b023af | ||
|
|
87019ff2af | ||
|
|
c320595423 | ||
|
|
00fb43c8ed | ||
|
|
2e7002b73b | ||
|
|
422e217180 | ||
|
|
eba7896893 | ||
|
|
1549971a13 | ||
|
|
291416544e | ||
|
|
c8696e67b5 | ||
|
|
45bc5bf8f6 | ||
|
|
fe6f9cca05 | ||
|
|
b7ad517174 | ||
|
|
9111117404 | ||
|
|
78efe32b69 | ||
|
|
35e8a61b1d | ||
|
|
9285a021c0 | ||
|
|
a751e9b680 | ||
|
|
a3f686ab3b |
No files matched your search
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"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'\\);\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+16
-27
@@ -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 ---
|
||||||
@@ -26,35 +26,24 @@ COOKIE_NAME=access_token
|
|||||||
# Set true when serving over HTTPS.
|
# Set true when serving over HTTPS.
|
||||||
COOKIE_SECURE=false
|
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=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=["*"]
|
||||||
|
|
||||||
# --- LDAP / Active Directory (optional) ---
|
|
||||||
# When enabled, login credentials are validated against the directory and a
|
|
||||||
# local user is provisioned on first login. Local DB auth still works as a
|
|
||||||
# fallback (so the seeded superuser can log in). Disabled by default.
|
|
||||||
LDAP_ENABLED=false
|
|
||||||
LDAP_SERVER_URI="ldap://ldap.example.com:389"
|
|
||||||
LDAP_USE_SSL=false
|
|
||||||
LDAP_START_TLS=false
|
|
||||||
LDAP_TLS_VALIDATE=true
|
|
||||||
# LDAP_CA_CERTS_FILE="/etc/ssl/certs/ca.pem"
|
|
||||||
|
|
||||||
# Option 1 — direct bind: build the user DN from the login name.
|
|
||||||
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
|
|
||||||
|
|
||||||
# Option 2 — search + bind: a service account finds the user, then we bind as
|
|
||||||
# the found DN. Leave the template above empty to use this mode.
|
|
||||||
# LDAP_BIND_DN="cn=svc-reader,dc=example,dc=com"
|
|
||||||
# LDAP_BIND_PASSWORD="service-account-password"
|
|
||||||
# LDAP_USER_SEARCH_BASE="ou=people,dc=example,dc=com"
|
|
||||||
# LDAP_USER_SEARCH_FILTER="(uid={username})"
|
|
||||||
|
|
||||||
# Directory attributes copied onto the local user, and roles granted on first login.
|
|
||||||
LDAP_ATTR_EMAIL=mail
|
|
||||||
LDAP_ATTR_FULL_NAME=cn
|
|
||||||
LDAP_DEFAULT_ROLES=["user"]
|
|
||||||
|
|
||||||
# --- Bootstrap superuser (used by scripts/seed.py) ---
|
# --- Bootstrap superuser (used by scripts/seed.py) ---
|
||||||
FIRST_SUPERUSER_EMAIL=admin@example.com
|
FIRST_SUPERUSER_EMAIL=admin@example.com
|
||||||
FIRST_SUPERUSER_PASSWORD=changethis
|
FIRST_SUPERUSER_PASSWORD=changethis
|
||||||
@@ -10,6 +10,8 @@ venv/
|
|||||||
|
|
||||||
# Databases
|
# Databases
|
||||||
*.db
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# Test / tooling
|
# Test / tooling
|
||||||
|
|||||||
@@ -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,47 +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`
|
||||||
- **Credential verification** goes through `services/auth.authenticate(db, username,
|
(preserving the original path as `?next=`).
|
||||||
password)` — both login routes call it, not `crud.user.authenticate` directly.
|
|
||||||
When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`,
|
|
||||||
sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local
|
|
||||||
row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`,
|
|
||||||
`LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in
|
|
||||||
`settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`.
|
|
||||||
- **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
@@ -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
@@ -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
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
VERSION := $(shell git describe --tags --always)
|
||||||
|
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:
|
||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ A production-shaped starting point for a web app that serves both **HTML (HTMX)*
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Concern | Choice |
|
| Concern | Choice |
|
||||||
| -------------------- | ----------------------------------------------------------- |
|
|----------------|--------|
|
||||||
| Web framework | FastAPI |
|
| Web framework | FastAPI |
|
||||||
| HTML / interactivity | Jinja2 + HTMX |
|
| HTML / interactivity | Jinja2 + HTMX |
|
||||||
| ORM | SQLAlchemy 2.0 (async) |
|
| ORM | SQLAlchemy 2.0 (async) |
|
||||||
@@ -154,7 +154,6 @@ Every setting is read from an environment variable first, then `.env`
|
|||||||
| `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 |
|
||||||
| `LDAP_ENABLED` | `false` | validate logins against LDAP/AD (see below) |
|
|
||||||
|
|
||||||
## Auth model
|
## Auth model
|
||||||
|
|
||||||
@@ -169,28 +168,6 @@ stores the access token in an httpOnly cookie so pages work without client JS.
|
|||||||
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
|
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
|
||||||
flag, so logout and rotation genuinely invalidate them.
|
flag, so logout and rotation genuinely invalidate them.
|
||||||
|
|
||||||
### LDAP / Active Directory (optional)
|
|
||||||
|
|
||||||
Set `LDAP_ENABLED=true` to validate logins against a directory. On a successful
|
|
||||||
bind, a local user is **provisioned on first login** (with an unusable local
|
|
||||||
password and the roles in `LDAP_DEFAULT_ROLES`) so RBAC, tokens, and the admin
|
|
||||||
UI work unchanged. Existing accounts still authenticate against the local
|
|
||||||
database as a fallback, so the seeded superuser keeps working.
|
|
||||||
|
|
||||||
Two lookup modes (configured in `.env`, see `.env.example`):
|
|
||||||
|
|
||||||
- **Direct bind** — set `LDAP_BIND_DN_TEMPLATE`, e.g.
|
|
||||||
`uid={username},ou=people,dc=example,dc=com`; the login name is substituted
|
|
||||||
and used to bind directly.
|
|
||||||
- **Search + bind** — leave the template empty and set `LDAP_BIND_DN` /
|
|
||||||
`LDAP_BIND_PASSWORD` (a service account) plus `LDAP_USER_SEARCH_BASE` /
|
|
||||||
`LDAP_USER_SEARCH_FILTER`; the user is located, then re-bound to verify the
|
|
||||||
password.
|
|
||||||
|
|
||||||
The local user's email/name come from the `LDAP_ATTR_EMAIL` / `LDAP_ATTR_FULL_NAME`
|
|
||||||
attributes. Use `ldaps://` (`LDAP_USE_SSL=true`) or `LDAP_START_TLS=true` in
|
|
||||||
production so credentials aren't sent in cleartext.
|
|
||||||
|
|
||||||
## RBAC
|
## RBAC
|
||||||
|
|
||||||
`users → roles → permissions`. Permissions follow `resource:action`
|
`users → roles → permissions`. Permissions follow `resource:action`
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
"""add auth_provider to users
|
|
||||||
|
|
||||||
Revision ID: 0002_add_auth_provider
|
|
||||||
Revises: 0001_initial
|
|
||||||
Create Date: 2026-07-19 00:00:00.000000
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "0002_add_auth_provider"
|
|
||||||
down_revision: str | None = "0001_initial"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column(
|
|
||||||
"auth_provider",
|
|
||||||
sa.String(length=20),
|
|
||||||
nullable=False,
|
|
||||||
server_default="local",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("users", "auth_provider")
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add part_colours
|
||||||
|
|
||||||
|
Revision ID: 0c3d1a5cff1f
|
||||||
|
Revises: 5b6a7817b408
|
||||||
|
Create Date: 2026-07-02 12:22:24.106795
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0c3d1a5cff1f"
|
||||||
|
down_revision: Union[str, None] = "5b6a7817b408"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add_fits_tables
|
||||||
|
|
||||||
|
Revision ID: 1b99be8a9b1e
|
||||||
|
Revises: adf4a5b9493e
|
||||||
|
Create Date: 2026-07-14 15:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "1b99be8a9b1e"
|
||||||
|
down_revision: Union[str, None] = "adf4a5b9493e"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""add static_connections
|
||||||
|
|
||||||
|
Revision ID: 5b6a7817b408
|
||||||
|
Revises: 7be5ef599d3f
|
||||||
|
Create Date: 2026-07-02 11:57:13.095417
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "5b6a7817b408"
|
||||||
|
down_revision: Union[str, None] = "7be5ef599d3f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""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
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "627d4460a94f"
|
||||||
|
down_revision: Union[str, None] = "0001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""add attachment_point_names
|
||||||
|
|
||||||
|
Revision ID: 70e48bda8829
|
||||||
|
Revises: c7882cedc9ae
|
||||||
|
Create Date: 2026-07-02 13:54:58.335394
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "70e48bda8829"
|
||||||
|
down_revision: Union[str, None] = "c7882cedc9ae"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add_imported_parts_tables
|
||||||
|
|
||||||
|
Revision ID: 7be5ef599d3f
|
||||||
|
Revises: b60d0aa3ceda
|
||||||
|
Create Date: 2026-06-25 15:29:20.685050
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7be5ef599d3f"
|
||||||
|
down_revision: Union[str, None] = "b60d0aa3ceda"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add_augmented_part_notes_table
|
||||||
|
|
||||||
|
Revision ID: 8012c00b0c04
|
||||||
|
Revises: d1e2f3a4b5c6
|
||||||
|
Create Date: 2026-07-31 09:07:06.137175
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8012c00b0c04"
|
||||||
|
down_revision: Union[str, None] = "d1e2f3a4b5c6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""add augmented_parts
|
||||||
|
|
||||||
|
Revision ID: 8bc411b5814e
|
||||||
|
Revises: 0c3d1a5cff1f
|
||||||
|
Create Date: 2026-07-02 12:37:59.457033
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8bc411b5814e"
|
||||||
|
down_revision: Union[str, None] = "0c3d1a5cff1f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add_manufacturers_table
|
||||||
|
|
||||||
|
Revision ID: a192abbf4091
|
||||||
|
Revises: 627d4460a94f
|
||||||
|
Create Date: 2026-06-25 10:32:11.398529
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a192abbf4091"
|
||||||
|
down_revision: Union[str, None] = "627d4460a94f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"series",
|
||||||
|
existing_type=sa.VARCHAR(length=8),
|
||||||
|
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table("manufacturers")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add_objective_lens_diameter_to_scope_infos
|
||||||
|
|
||||||
|
Revision ID: a3f7c19b2d40
|
||||||
|
Revises: 8012c00b0c04
|
||||||
|
Create Date: 2026-08-04 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a3f7c19b2d40"
|
||||||
|
down_revision: Union[str, None] = "8012c00b0c04"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,39 @@
|
|||||||
|
"""add_scope_models_table
|
||||||
|
|
||||||
|
Revision ID: a52e7ca7a925
|
||||||
|
Revises: a192abbf4091
|
||||||
|
Create Date: 2026-06-25 10:35:40.227253
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a52e7ca7a925"
|
||||||
|
down_revision: Union[str, None] = "a192abbf4091"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""add attachment_points
|
||||||
|
|
||||||
|
Revision ID: adf4a5b9493e
|
||||||
|
Revises: 70e48bda8829
|
||||||
|
Create Date: 2026-07-02 14:03:23.561253
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "adf4a5b9493e"
|
||||||
|
down_revision: Union[str, None] = "70e48bda8829"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add_sub_part_types_table
|
||||||
|
|
||||||
|
Revision ID: b60d0aa3ceda
|
||||||
|
Revises: fd5379986da4
|
||||||
|
Create Date: 2026-06-25 11:29:20.420056
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b60d0aa3ceda"
|
||||||
|
down_revision: Union[str, None] = "fd5379986da4"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add augmented_part_imported_parts
|
||||||
|
|
||||||
|
Revision ID: bfbce324cd5f
|
||||||
|
Revises: 8bc411b5814e
|
||||||
|
Create Date: 2026-07-02 12:46:27.493942
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "bfbce324cd5f"
|
||||||
|
down_revision: Union[str, None] = "8bc411b5814e"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add clamp_connections
|
||||||
|
|
||||||
|
Revision ID: c7882cedc9ae
|
||||||
|
Revises: bfbce324cd5f
|
||||||
|
Create Date: 2026-07-02 13:31:57.064381
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c7882cedc9ae"
|
||||||
|
down_revision: Union[str, None] = "bfbce324cd5f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -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,38 @@
|
|||||||
|
"""add_scope_infos_table
|
||||||
|
|
||||||
|
Revision ID: d1e2f3a4b5c6
|
||||||
|
Revises: c9f1a2b3d4e5
|
||||||
|
Create Date: 2026-07-16 20:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "d1e2f3a4b5c6"
|
||||||
|
down_revision: Union[str, None] = "c9f1a2b3d4e5"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,35 @@
|
|||||||
|
"""add_part_types_table
|
||||||
|
|
||||||
|
Revision ID: e23aa8dd2536
|
||||||
|
Revises: a52e7ca7a925
|
||||||
|
Create Date: 2026-06-25 10:49:56.463466
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "e23aa8dd2536"
|
||||||
|
down_revision: Union[str, None] = "a52e7ca7a925"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""add_audit_logs_table
|
||||||
|
|
||||||
|
Revision ID: fd5379986da4
|
||||||
|
Revises: e23aa8dd2536
|
||||||
|
Create Date: 2026-06-25 11:06:13.028067
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "fd5379986da4"
|
||||||
|
down_revision: Union[str, None] = "e23aa8dd2536"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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 "—"
|
||||||
|
|
||||||
|
|
||||||
|
@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="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
|
||||||
|
)
|
||||||
|
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
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(require_permission("augmented_parts:read")),
|
||||||
|
):
|
||||||
|
return await augmented_part_crud.list_all(db, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@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.deleted} deleted, "
|
||||||
|
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
|
||||||
|
)
|
||||||
+35
-11
@@ -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,32 +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.auth import (
|
from app.services import ldap as ldap_service
|
||||||
RefreshError,
|
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
|
||||||
authenticate,
|
|
||||||
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
|
||||||
|
|
||||||
@@ -45,8 +48,8 @@ async def login(
|
|||||||
form: OAuth2PasswordRequestForm = Depends(),
|
form: OAuth2PasswordRequestForm = Depends(),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
|
# OAuth2 form uses ``username``; we treat it as the email.
|
||||||
user = await authenticate(db, form.username, form.password)
|
user = await user_crud.authenticate(db, form.username, form.password)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -57,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:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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 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"
|
||||||
|
)
|
||||||
|
await imported_part_crud.delete(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),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
@@ -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),
|
||||||
|
)
|
||||||
+13
-34
@@ -37,43 +37,22 @@ class Settings(BaseSettings):
|
|||||||
cookie_name: str = "access_token"
|
cookie_name: str = "access_token"
|
||||||
cookie_secure: bool = False
|
cookie_secure: bool = False
|
||||||
|
|
||||||
|
# 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] = ["*"]
|
||||||
|
|
||||||
# --- LDAP / Active Directory (optional) --------------------------------
|
|
||||||
# When ``ldap_enabled`` is true, login credentials are validated by binding
|
|
||||||
# to the directory. A local ``User`` row is provisioned on first successful
|
|
||||||
# login so RBAC, tokens, and the admin UI keep working. Local database auth
|
|
||||||
# remains available as a fallback (so the seeded superuser can still log in).
|
|
||||||
ldap_enabled: bool = False
|
|
||||||
# e.g. "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"
|
|
||||||
ldap_server_uri: str = ""
|
|
||||||
ldap_use_ssl: bool = False
|
|
||||||
ldap_start_tls: bool = False
|
|
||||||
# Verify the server's TLS certificate (ldaps/StartTLS). Disable only for dev.
|
|
||||||
ldap_tls_validate: bool = True
|
|
||||||
ldap_ca_certs_file: str = ""
|
|
||||||
|
|
||||||
# Two ways to locate/authenticate the user (direct bind takes precedence):
|
|
||||||
#
|
|
||||||
# 1. Direct bind — build the user's DN from the login name, then bind as it:
|
|
||||||
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
|
|
||||||
ldap_bind_dn_template: str = ""
|
|
||||||
#
|
|
||||||
# 2. Search + bind — bind with a service account (or anonymously), search for
|
|
||||||
# the user, then re-bind as the found DN to verify the password:
|
|
||||||
ldap_bind_dn: str = ""
|
|
||||||
ldap_bind_password: str = ""
|
|
||||||
ldap_user_search_base: str = ""
|
|
||||||
ldap_user_search_filter: str = "(uid={username})"
|
|
||||||
|
|
||||||
# Attributes read from the directory entry to populate the local user.
|
|
||||||
ldap_attr_email: str = "mail"
|
|
||||||
ldap_attr_full_name: str = "cn"
|
|
||||||
|
|
||||||
# Roles (by name) granted to a user provisioned from LDAP on first login.
|
|
||||||
ldap_default_roles: list[str] = ["user"]
|
|
||||||
|
|
||||||
# Bootstrap superuser (used by scripts/seed.py)
|
# Bootstrap superuser (used by scripts/seed.py)
|
||||||
first_superuser_email: str = "admin@example.com"
|
first_superuser_email: str = "admin@example.com"
|
||||||
first_superuser_password: str = "changethis"
|
first_superuser_password: str = "changethis"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Password hashing and JWT token helpers."""
|
"""Password hashing and JWT token helpers."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
@@ -28,16 +27,6 @@ def verify_password(password: str, hashed: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def make_unusable_password() -> str:
|
|
||||||
"""A sentinel hash that no password can match.
|
|
||||||
|
|
||||||
Used for externally-authenticated users (e.g. LDAP) who have no local
|
|
||||||
password. It is not a valid bcrypt hash, so ``verify_password`` always
|
|
||||||
returns ``False`` for it — local password auth can never succeed.
|
|
||||||
"""
|
|
||||||
return "!" + secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
# --- JWT ---------------------------------------------------------------------
|
# --- JWT ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
from sqlalchemy import distinct, 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
|
||||||
|
) -> list[AugmentedPart]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AugmentedPart)
|
||||||
|
.order_by(AugmentedPart.description)
|
||||||
|
.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,
|
||||||
|
sort: str = "description",
|
||||||
|
direction: str = "asc",
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[AugmentedPart]:
|
||||||
|
q = select(AugmentedPart)
|
||||||
|
if description.strip():
|
||||||
|
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
) -> int:
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
q = select(func.count()).select_from(AugmentedPart)
|
||||||
|
if description.strip():
|
||||||
|
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
|
||||||
|
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)
|
||||||
|
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 delete(db: AsyncSession, ap: AugmentedPart) -> None:
|
||||||
|
await db.delete(ap)
|
||||||
|
await db.commit()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from sqlalchemy import distinct, insert, 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():
|
||||||
|
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||||
|
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():
|
||||||
|
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, part: ImportedPart) -> None:
|
||||||
|
await db.delete(part)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
|
||||||
|
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
|
||||||
|
if not records:
|
||||||
|
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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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,
|
||||||
|
objective_thread_pitch: str | 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,
|
||||||
|
objective_thread_pitch=objective_thread_pitch,
|
||||||
|
)
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+35
-42
@@ -1,9 +1,9 @@
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.security import hash_password, make_unusable_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())
|
||||||
@@ -32,29 +37,24 @@ async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_roles_by_name(db: AsyncSession, names: list[str]) -> list[Role]:
|
|
||||||
if not names:
|
|
||||||
return []
|
|
||||||
result = await db.execute(select(Role).where(Role.name.in_(names)))
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def create(
|
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,
|
||||||
auth_provider: str = "local",
|
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,
|
||||||
auth_provider=auth_provider,
|
|
||||||
roles=await _resolve_roles(db, role_ids or []),
|
roles=await _resolve_roles(db, role_ids or []),
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -63,45 +63,35 @@ async def create(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def upsert_ldap_user(
|
async def get_or_create_ldap_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
username: str,
|
||||||
email: str,
|
email: str,
|
||||||
full_name: str | None = None,
|
full_name: str | None,
|
||||||
default_role_names: list[str] | None = None,
|
role_ids: list[int] | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Provision (or refresh) the local row for an LDAP-authenticated user.
|
"""Find an LDAP user by username, provisioning a local profile on first login.
|
||||||
|
|
||||||
On first login the user is created with an unusable local password (so
|
The profile is keyed on the LDAP username; email is synthesized by the
|
||||||
password auth can never succeed) and the configured default roles. On later
|
caller. Newly provisioned users get ``role_ids`` (the default role).
|
||||||
logins the existing row is reused; only ``full_name`` is synced from the
|
|
||||||
directory. Roles assigned afterwards in the admin UI are preserved.
|
|
||||||
"""
|
"""
|
||||||
user = await get_by_email(db, email)
|
user = await get_by_username(db, username)
|
||||||
if user is None:
|
if user is not None:
|
||||||
user = User(
|
# Keep the display name in sync with LDAP on subsequent logins.
|
||||||
email=email,
|
|
||||||
hashed_password=make_unusable_password(),
|
|
||||||
full_name=full_name,
|
|
||||||
auth_provider="ldap",
|
|
||||||
roles=await _resolve_roles_by_name(db, default_role_names or []),
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if user.auth_provider != "ldap":
|
|
||||||
user.auth_provider = "ldap"
|
|
||||||
changed = True
|
|
||||||
if full_name and user.full_name != full_name:
|
if full_name and user.full_name != full_name:
|
||||||
user.full_name = full_name
|
user.full_name = full_name
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return 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:
|
||||||
@@ -131,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
@@ -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 not in ("/", "/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():
|
||||||
|
|||||||
+43
-1
@@ -1,16 +1,58 @@
|
|||||||
"""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.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.scope_info import ScopeInfo
|
||||||
|
from app.models.scope_model import ScopeModel
|
||||||
|
from app.models.static_connection import StaticConnection
|
||||||
|
from app.models.sub_part_type import SubPartType
|
||||||
from app.models.refresh_token import RefreshToken
|
from app.models.refresh_token import RefreshToken
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
|
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",
|
||||||
|
"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",
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exactly one of the four below must be non-null (enforced in application layer)
|
||||||
|
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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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")
|
||||||
@@ -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)
|
||||||
@@ -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(str, enum.Enum):
|
||||||
|
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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
# Kept as the raw string from the sheet (e.g. "0.75", "1/16") so no precision
|
||||||
|
# or notation is lost; the parsed thread lives on its own AttachmentPoint.
|
||||||
|
objective_thread_pitch: Mapped[str | None] = mapped_column(
|
||||||
|
String, nullable=True, default=None
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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(str, enum.Enum):
|
||||||
|
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
-5
@@ -6,20 +6,26 @@ 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)
|
||||||
# Where this account authenticates: "local" (password) or "ldap".
|
|
||||||
auth_provider: Mapped[str] = mapped_column(
|
|
||||||
String(20), default="local", server_default="local"
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
user_id: int | None
|
||||||
|
user_email: str
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: int | None
|
||||||
|
description: str
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
|
from app.schemas.manufacturer import ManufacturerRead
|
||||||
|
from app.schemas.part_colour import PartColourRead
|
||||||
|
from app.schemas.part_type import PartTypeRead
|
||||||
|
from app.schemas.scope_model import ScopeModelRead
|
||||||
|
from app.schemas.sub_part_type import SubPartTypeRead
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartNoteCreate(BaseModel):
|
||||||
|
body: str
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def validate_body(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("body must not be blank")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartNoteRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
augmented_part_id: int
|
||||||
|
user_id: int | None
|
||||||
|
username: str
|
||||||
|
body: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartBase(BaseModel):
|
||||||
|
sku: str | None = None
|
||||||
|
description: str
|
||||||
|
colour_id: int
|
||||||
|
manufacturer_id: int
|
||||||
|
scope_model_id: int | None = None
|
||||||
|
part_type_id: int
|
||||||
|
sub_part_type_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartCreate(AugmentedPartBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartUpdate(BaseModel):
|
||||||
|
sku: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartSyncResult(BaseModel):
|
||||||
|
created: int
|
||||||
|
deleted: int
|
||||||
|
skipped: int
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentedPartRead(AugmentedPartBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
colour: PartColourRead
|
||||||
|
manufacturer: ManufacturerRead
|
||||||
|
scope_model: ScopeModelRead | None
|
||||||
|
part_type: PartTypeRead
|
||||||
|
sub_part_type: SubPartTypeRead | None
|
||||||
|
notes: list[AugmentedPartNoteRead] = []
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ClampConnectionBase(BaseModel):
|
||||||
|
min_mm: float | None = None
|
||||||
|
max_mm: float | None = None
|
||||||
|
min_in: float | None = None
|
||||||
|
max_in: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClampConnectionCreate(ClampConnectionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClampConnectionUpdate(ClampConnectionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClampConnectionRead(ClampConnectionBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user