Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81c368663 | ||
|
|
8860c5691f | ||
|
|
054770d370 | ||
|
|
4fd2bb1b58 | ||
|
|
13b64a2d17 | ||
|
|
d23f5b9e82 | ||
|
|
597cf68157 | ||
|
|
d8db87c13e | ||
|
|
af5343da0c | ||
|
|
90cae15551 | ||
|
|
16ba09f073 | ||
|
|
53fe03fb2d | ||
|
|
c414708b32 | ||
|
|
443a128ad7 | ||
|
|
167ef3a409 | ||
|
|
b4dcb68bea | ||
|
|
7e246be39c | ||
|
|
34d538f9a0 | ||
|
|
9a9ebab7c8 | ||
|
|
3253d8e858 | ||
|
|
2ce4f91e4f | ||
|
|
6806caf8d7 | ||
|
|
387ae49efc | ||
|
|
b7c4b05150 | ||
|
|
413e9e8381 | ||
|
|
740dfc97c7 | ||
|
|
3237824e83 | ||
|
|
082ad16963 | ||
|
|
85cb58ef65 | ||
|
|
ab5315b862 | ||
|
|
4d6fda6f74 | ||
|
|
a35d064e32 | ||
|
|
ce2bdc43a8 | ||
|
|
292830ae0f | ||
|
|
067ddffbe5 | ||
|
|
bdb6c7ebfc | ||
|
|
2318a6daeb | ||
|
|
6d7c5c5604 | ||
|
|
7a15d9f7c0 | ||
|
|
a9c4c16d4f | ||
|
|
14aa2ac5d6 | ||
|
|
f4771e33d4 | ||
|
|
cf8d72f73e | ||
|
|
2e7976e382 | ||
|
|
2a0b8fcab2 | ||
|
|
392b79e801 | ||
|
|
3ebe9a0894 | ||
|
|
8d37b2ad67 | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(uv run *)",
|
||||
"Bash(uv sync *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")",
|
||||
"Bash(PYTHONPATH=. uv run *)",
|
||||
"Bash(python *)",
|
||||
"Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)",
|
||||
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
|
||||
"Bash(docker build *)",
|
||||
"Bash(git status *)",
|
||||
"Bash(git -C /home/iroddis/dev/armament/fitguide status --short)",
|
||||
"Bash(git -C /home/iroddis/dev/armament/fitguide log --oneline -10)",
|
||||
"Bash(node --check /Users/roddis/dev/armament/fitguide/app/static/js/sortable-tables.js)",
|
||||
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from users;\")",
|
||||
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from part_colours;\")",
|
||||
"Bash(sqlite3 /Users/roddis/dev/armament/fitguide/app.db \"select count\\(*\\) from manufacturers;\")",
|
||||
"Bash(curl -s -c /tmp/fg_cookies.txt -o /dev/null -w \"login:%{http_code}\\\\n\" -d \"email=admin@armament.com&password=changethis\" http://localhost:8000/login)",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/admin/manufacturers)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\\\\n\" http://localhost:8000/static/js/sortable-tables.js)",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts\")",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/augmented-parts/filter?sort=colour&dir=desc\")",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt \"http://localhost:8000/admin/roles\")",
|
||||
"Bash(curl -s http://localhost:8000/scopes)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/scopes)",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000/scopes)",
|
||||
"Bash(lsof -ti:8000 -sTCP:LISTEN)",
|
||||
"Bash(xargs -r kill)",
|
||||
"Bash(rm -f /tmp/fg_cookies.txt /private/tmp/claude-501/-Users-roddis-dev-armament-fitguide/7873fd9c-2f2b-4583-877e-72fc88718f24/scratchpad/smoke_sort.py)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:8000/login)",
|
||||
"Bash(curl -s -b /tmp/fg_cookies.txt http://localhost:8000__TRACKED_VAR__)",
|
||||
"Bash(sqlite3 app.db \"PRAGMA foreign_keys=ON; SELECT scope_model_id FROM augmented_parts WHERE id=1862;\")",
|
||||
"Bash(sqlite3 app.db \"SELECT id, name FROM scope_models WHERE name='Viper';\")",
|
||||
"Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")",
|
||||
"Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)",
|
||||
"Bash(rg -n \"hx-delete|router.delete\" app/web/admin.py app/templates/admin/*.html)",
|
||||
"Bash(rg -n \"is_superuser\" tests/conftest.py tests/*.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -25,6 +25,24 @@ AUTO_CREATE_TABLES=true
|
||||
COOKIE_NAME=access_token
|
||||
# Set true when serving over HTTPS.
|
||||
COOKIE_SECURE=false
|
||||
# How long a browser login lasts before being bounced back to /login.
|
||||
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
|
||||
SESSION_TIMEOUT_HOURS=24
|
||||
|
||||
# --- LDAP (optional; direct-bind) ---
|
||||
# When enabled, the login page and /api/auth/ldap-login accept a directory
|
||||
# username + password. On first login a local profile is provisioned using the
|
||||
# username and the default role (mark one in the Roles admin UI).
|
||||
LDAP_ENABLED=false
|
||||
LDAP_HOST=localhost
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=false
|
||||
# "{username}" is substituted with the submitted username to form the bind DN.
|
||||
LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
|
||||
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
|
||||
LDAP_EMAIL_DOMAIN=example.com
|
||||
# LDAP attribute read for the provisioned user's display name.
|
||||
LDAP_FULL_NAME_ATTR=cn
|
||||
|
||||
# --- CORS (JSON list) ---
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
@@ -10,6 +10,8 @@ venv/
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite3
|
||||
|
||||
# Test / tooling
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repo. Read alongside `README.md` (user-facing setup).
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Guidance for working in this repo. Read alongside `README.md` (user-facing setup
|
||||
for the original FastAPI skeleton this app was built from).
|
||||
|
||||
## What this is
|
||||
|
||||
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered
|
||||
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth
|
||||
with refresh-token revocation, and RBAC (users → roles → permissions).
|
||||
**Fitguide**: a parts-compatibility guide for Armament scopes and accessories,
|
||||
built on a FastAPI skeleton that serves **both** a JSON API (`/api/*`) and
|
||||
server-rendered HTML via **Jinja2 + HTMX** (`/`, `/scopes`, `/admin/*`). Async
|
||||
SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation (plus optional LDAP
|
||||
direct-bind login), and RBAC (users → roles → permissions). The domain layer on
|
||||
top of the skeleton tracks parts, their attachment points, and which parts fit
|
||||
together — see "Domain model" below.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
uv sync --extra dev # install (incl. dev tools)
|
||||
uv run alembic upgrade head # apply migrations
|
||||
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
|
||||
uv run uvicorn app.main:app --reload # dev server on :8000
|
||||
uv run pytest # tests (in-memory SQLite)
|
||||
uv run pytest tests/test_foo.py::test_bar # single test
|
||||
uv run ruff check . [--fix] # lint
|
||||
uv run alembic upgrade head # apply migrations
|
||||
uv run black . # format (ruff does not format here)
|
||||
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
|
||||
docker compose up --build # app + Postgres, auto-seeded
|
||||
```
|
||||
|
||||
`Makefile` wraps the common ones (`make sync|seed|dev|test|fmt|migrate`).
|
||||
`make clean` deletes the local `app.db`, re-migrates, and reseeds — the quick
|
||||
way to get a clean dev database. `make seed` runs `alembic upgrade head` before
|
||||
seeding, i.e. day-to-day dev relies on migrations, not just
|
||||
`AUTO_CREATE_TABLES` (see "Database").
|
||||
|
||||
## Architecture / layering
|
||||
|
||||
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
|
||||
@@ -28,40 +43,106 @@ Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data ac
|
||||
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
|
||||
`crud/`, not in routers.
|
||||
|
||||
`app/api/` is one router module per resource, all mounted under `/api` by
|
||||
`app/api/router.py`. `app/web/` is organized differently: `pages.py` (dashboard/
|
||||
login), `scopes.py` (the public-facing `/scopes` fit guide), and **`admin.py`**
|
||||
— a single ~3000-line router covering every admin screen (users, roles, part
|
||||
taxonomy tables, imported parts, augmented parts + detail + nested attachment
|
||||
points/fits, audit log, etc.), organized into `# --- Section ---` banner
|
||||
comments per resource rather than split into files. When adding an admin screen,
|
||||
follow the existing banner-section convention there rather than starting a new
|
||||
web router module.
|
||||
|
||||
## Domain model
|
||||
|
||||
The business domain, independent of the auth/RBAC skeleton:
|
||||
|
||||
- **`AugmentedPart`** (`augmented_parts`) is the generic "part" entity — a
|
||||
scope, flip cover, ARD, polarizer, adapter ring, etc. — distinguished by
|
||||
`PartType`/`SubPartType`, with `PartColour`, `Manufacturer`, and an optional
|
||||
`ScopeModel`. "Scope" is just one `PartType` value (see `SCOPE_TYPE_NAME` in
|
||||
`app/services/scope_guide.py`), not a separate table.
|
||||
- **`ScopeInfo`** is a 1:1 extension of `AugmentedPart` (magnification,
|
||||
objective thread, model suffix) populated only for scope-type parts.
|
||||
- **`AttachmentPoint`** belongs to one `AugmentedPart`, is named via
|
||||
`AttachmentPointName` (e.g. "Ocular", "Objective"), and connects via
|
||||
*exactly one* of `IsoThread` / `UncThread` / `ClampConnection` /
|
||||
`StaticConnection` — that "exactly one" invariant is enforced in the app
|
||||
layer (CRUD/API validation), not by a DB constraint.
|
||||
- **`Fit`** (`app/models/fit.py`) pairs two `AttachmentPoint`s (canonical
|
||||
ordering enforced in `app/crud/fit.py` so a pair only ever has one row),
|
||||
labeled `possible` / `exact` / `none`, with threaded `FitNote`s.
|
||||
`app/services/fit_matching.py` computes `possible` fits automatically
|
||||
(matching ISO/UNC thread specs exactly, or a static connection's dimension
|
||||
falling inside a clamp connection's range); `exact`/`none` are user-asserted
|
||||
and never touched by the algorithm.
|
||||
- **`ImportedPart`** (`imported_parts`, PK is the string `InventoryID` — not an
|
||||
int id) holds raw rows from an external inventory feed, fetched via
|
||||
`app/services/odata.py` (OData v4, paginated). `app/services/
|
||||
augmented_part_sync.py` reconciles these into `AugmentedPart` +
|
||||
`AttachmentPoint` rows: infers `PartType` by substring match on the
|
||||
description, parses `TBXThreadAttribute` against ISO/UNC regexes (falling
|
||||
back to an OD-derived `StaticConnection`), and prunes `AugmentedPart`s whose
|
||||
backing `ImportedPart`s have disappeared.
|
||||
- **`app/services/scope_import.py`** is a separate, manually-triggered bulk
|
||||
importer: reads an uploaded `.xlsx` of third-party scopes, upserting by
|
||||
`product_number` (→ `AugmentedPart.sku`) and creating `ScopeModel`,
|
||||
`ScopeInfo`, up to two `AttachmentPoint`s, and up to two `Fit`s per row
|
||||
(see the module docstring for the full column mapping).
|
||||
- **`app/services/scope_guide.py`** assembles the `/scopes` page: one row per
|
||||
Scope-type `AugmentedPart`, one column per other `PartType`, each cell
|
||||
listing parts of that type with a recorded `Fit`.
|
||||
|
||||
## Conventions that matter
|
||||
|
||||
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
|
||||
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
|
||||
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so
|
||||
attributes are safe to read after the session closes — required under async.
|
||||
Preserve this when adding relationships that templates/schemas read.
|
||||
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`,
|
||||
`AttachmentPoint.iso_thread`, etc.) so attributes are safe to read after the
|
||||
session closes — required under async. Preserve this when adding relationships
|
||||
that templates/schemas read.
|
||||
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
|
||||
they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
|
||||
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
|
||||
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
|
||||
all checks (`User.is_superuser` / `User.has_permission`). Add new permission
|
||||
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
|
||||
- **HTMX UI-level write gating**: on top of route-level RBAC, pages that mix
|
||||
read-only and read-write viewers (e.g. `augmented_part_detail.html`'s Fits and
|
||||
Attachment Points sections) compute a `can_write_<thing>` bool from the
|
||||
current user's permissions and wrap add/edit/delete controls in
|
||||
`{% if can_write_<thing> %}` in the template, so read-only users see data but
|
||||
not controls they'd get a 403 on. The route-level `web_require(...)` check
|
||||
remains the real security boundary; the template flag is only for UX.
|
||||
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
|
||||
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
|
||||
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
|
||||
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`
|
||||
(preserving the original path as `?next=`).
|
||||
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
|
||||
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
|
||||
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
|
||||
the token) — pass it straight to the model, don't `fromtimestamp` it.
|
||||
- **LDAP login** (`app/services/ldap.py`) is optional (`settings.ldap_enabled`),
|
||||
direct-bind, and just-in-time provisions a local `User` on first successful
|
||||
login. `ldap3` is sync, so its calls run via `run_in_threadpool`.
|
||||
|
||||
## Database
|
||||
|
||||
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
|
||||
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
|
||||
on startup for dev convenience; production should use Alembic instead. `init_db()`
|
||||
imports `app.models` inside the function to avoid a circular import.
|
||||
on startup for dev convenience, but the day-to-day `make seed`/`make clean` flow
|
||||
runs `alembic upgrade head` first — treat Alembic migrations as the source of
|
||||
truth and keep them in sync with model changes. `init_db()` imports `app.models`
|
||||
inside the function to avoid a circular import.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
|
||||
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
|
||||
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*`
|
||||
without decorators.
|
||||
(perms+roles from `scripts.seed.PERMISSIONS`/`ROLES`), `make_user`, and
|
||||
`db_sessionmaker` (used directly in tests to seed domain rows — models, fits,
|
||||
attachment points — beyond what `seeded`/`make_user` cover). `asyncio_mode =
|
||||
"auto"` — write `async def test_*` without decorators.
|
||||
|
||||
## When extending
|
||||
|
||||
|
||||
+5
-2
@@ -18,8 +18,11 @@ RUN uv sync --no-dev
|
||||
|
||||
# Copy the application.
|
||||
COPY . .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# If sqlite3 is used
|
||||
VOLUME /data
|
||||
EXPOSE 8000
|
||||
|
||||
# Default: run the API server. docker-compose overrides this to also seed.
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server.
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -21,3 +21,4 @@
|
||||
- Models
|
||||
- Colours
|
||||
- Part Types
|
||||
- Notes
|
||||
@@ -1,10 +1,14 @@
|
||||
VERSION := $(shell git tag | tail -1)
|
||||
IMAGE_NAME := fitguide
|
||||
|
||||
sync:
|
||||
uv sync --extra dev
|
||||
|
||||
seed:
|
||||
uv run alembic upgrade head
|
||||
uv run python -m scripts.seed
|
||||
|
||||
dev:
|
||||
dev: migrate
|
||||
uv run uvicorn app.main:app --reload
|
||||
open http://localhost:8000
|
||||
|
||||
@@ -17,3 +21,19 @@ migrate:
|
||||
|
||||
fmt:
|
||||
uv run black .
|
||||
|
||||
clean:
|
||||
rm app.db
|
||||
uv run alembic upgrade head
|
||||
uv run python -m scripts.seed
|
||||
|
||||
docker:
|
||||
mkdir -p artifacts
|
||||
docker build -t $(IMAGE_NAME):$(VERSION) .
|
||||
|
||||
docker-push: docker
|
||||
docker tag $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest
|
||||
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
|
||||
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
|
||||
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
|
||||
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
|
||||
@@ -149,8 +149,9 @@ Every setting is read from an environment variable first, then `.env`
|
||||
|----------|---------|-------|
|
||||
| `SECRET_KEY` | dev placeholder | **set a strong value in production** |
|
||||
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
|
||||
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
|
||||
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
|
||||
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
|
||||
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
|
||||
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[ ] Drop 'id' from UI tables, add 'date added / updated'
|
||||
[x] Adapter Rings should get two attachment points
|
||||
[ ] Add 'on_website' boolean to augmented part
|
||||
[x] API: allow updating parts by ID or by SKU
|
||||
[ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
|
||||
[ ] Adapter Rings
|
||||
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add jobs table
|
||||
|
||||
Revision ID: 04425ce3d4d5
|
||||
Revises: a3f7c19b2d40
|
||||
Create Date: 2026-08-05 15:21:09.527442
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "04425ce3d4d5"
|
||||
down_revision: str | None = "a3f7c19b2d40"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"jobs",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("kind", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("result", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("jobs")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add part_colours
|
||||
|
||||
Revision ID: 0c3d1a5cff1f
|
||||
Revises: 5b6a7817b408
|
||||
Create Date: 2026-07-02 12:22:24.106795
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0c3d1a5cff1f"
|
||||
down_revision: str | None = "5b6a7817b408"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"part_colours",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("part_colours")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""manufacturers_url_nullable
|
||||
|
||||
Revision ID: 0fec09fb6ae6
|
||||
Revises: 9a1c2f6e0b3d
|
||||
Create Date: 2026-09-04 23:05:18.990806
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0fec09fb6ae6"
|
||||
down_revision: str | None = "9a1c2f6e0b3d"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"url",
|
||||
existing_type=sa.String(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE manufacturers SET url = '' WHERE url IS NULL")
|
||||
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"url",
|
||||
existing_type=sa.String(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""add_fits_tables
|
||||
|
||||
Revision ID: 1b99be8a9b1e
|
||||
Revises: adf4a5b9493e
|
||||
Create Date: 2026-07-14 15:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1b99be8a9b1e"
|
||||
down_revision: str | None = "adf4a5b9493e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"fits",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("attachment_point_a_id", sa.Integer(), nullable=False),
|
||||
sa.Column("attachment_point_b_id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_a_id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_b_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"label",
|
||||
sa.Enum("possible", "exact", "none", name="fitlabel"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["attachment_point_b_id"], ["attachment_points.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_a_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_b_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"fit_notes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("fit_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("username", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("fit_notes")
|
||||
op.drop_table("fits")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""augmented_parts_sku_unique
|
||||
|
||||
Revision ID: 1c2d3e4f5a6b
|
||||
Revises: 0fec09fb6ae6
|
||||
Create Date: 2026-09-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1c2d3e4f5a6b"
|
||||
down_revision: str | None = "0fec09fb6ae6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.create_index("ix_augmented_parts_sku", ["sku"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_augmented_parts_sku")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""drop_objective_thread_pitch_from_scope_infos
|
||||
|
||||
Revision ID: 235d823a56ee
|
||||
Revises: 04425ce3d4d5
|
||||
Create Date: 2026-08-17 15:18:26.763831
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "235d823a56ee"
|
||||
down_revision: str | None = "04425ce3d4d5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("scope_infos", schema=None) as batch_op:
|
||||
batch_op.drop_column("objective_thread_pitch")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("scope_infos", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("objective_thread_pitch", sa.String(), nullable=True)
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""imported_parts_uac_diameter_range
|
||||
|
||||
Revision ID: 2b3c4d5e6f7a
|
||||
Revises: 1c2d3e4f5a6b
|
||||
Create Date: 2026-09-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2b3c4d5e6f7a"
|
||||
down_revision: str | None = "1c2d3e4f5a6b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("UACDiameterRangemm", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
|
||||
batch_op.drop_column("UACDiameterRangemm")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""augmented_parts_uac_diameter_range
|
||||
|
||||
Revision ID: 3c4d5e6f7a8b
|
||||
Revises: 2b3c4d5e6f7a
|
||||
Create Date: 2026-09-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3c4d5e6f7a8b"
|
||||
down_revision: str | None = "2b3c4d5e6f7a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("uac_diameter_range_mm", sa.String(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.drop_column("uac_diameter_range_mm")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""augmented_parts_on_website_preferred
|
||||
|
||||
Revision ID: 4d5e6f7a8b9c
|
||||
Revises: 3c4d5e6f7a8b
|
||||
Create Date: 2026-09-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4d5e6f7a8b9c"
|
||||
down_revision: str | None = "3c4d5e6f7a8b"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"on_website", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"preferred_part",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.drop_column("preferred_part")
|
||||
batch_op.drop_column("on_website")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add static_connections
|
||||
|
||||
Revision ID: 5b6a7817b408
|
||||
Revises: 7be5ef599d3f
|
||||
Create Date: 2026-07-02 11:57:13.095417
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5b6a7817b408"
|
||||
down_revision: str | None = "7be5ef599d3f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"static_connections",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("od_mm", sa.Float(), nullable=False),
|
||||
sa.Column("od_in", sa.Float(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("od_mm"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("static_connections")
|
||||
@@ -6,16 +6,15 @@ Create Date: 2026-06-24 21:50:05.340235
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "627d4460a94f"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
down_revision: str | None = "0001_initial"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add attachment_point_names
|
||||
|
||||
Revision ID: 70e48bda8829
|
||||
Revises: c7882cedc9ae
|
||||
Create Date: 2026-07-02 13:54:58.335394
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "70e48bda8829"
|
||||
down_revision: str | None = "c7882cedc9ae"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"attachment_point_names",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("attachment_point_names")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add_imported_parts_tables
|
||||
|
||||
Revision ID: 7be5ef599d3f
|
||||
Revises: b60d0aa3ceda
|
||||
Create Date: 2026-06-25 15:29:20.685050
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7be5ef599d3f"
|
||||
down_revision: str | None = "b60d0aa3ceda"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"imported_parts",
|
||||
sa.Column("InventoryID", sa.String(), nullable=False),
|
||||
sa.Column("Description", sa.String(), nullable=True),
|
||||
sa.Column("TBXColour", sa.String(), nullable=True),
|
||||
sa.Column("ProductMaxThreadDepthmm", sa.String(), nullable=True),
|
||||
sa.Column("ProductMaxThreadDepthin", sa.String(), nullable=True),
|
||||
sa.Column("ProductODmm", sa.String(), nullable=True),
|
||||
sa.Column("ProductODin", sa.String(), nullable=True),
|
||||
sa.Column("TBXThreadAttribute", sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("InventoryID"),
|
||||
)
|
||||
op.create_table(
|
||||
"imported_part_config",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("odata_url", sa.String(), nullable=True),
|
||||
sa.Column("username", sa.String(), nullable=True),
|
||||
sa.Column("password", sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("imported_part_config")
|
||||
op.drop_table("imported_parts")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add_augmented_part_notes_table
|
||||
|
||||
Revision ID: 8012c00b0c04
|
||||
Revises: d1e2f3a4b5c6
|
||||
Create Date: 2026-07-31 09:07:06.137175
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8012c00b0c04"
|
||||
down_revision: str | None = "d1e2f3a4b5c6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"augmented_part_notes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("username", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("augmented_part_notes")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add augmented_parts
|
||||
|
||||
Revision ID: 8bc411b5814e
|
||||
Revises: 0c3d1a5cff1f
|
||||
Create Date: 2026-07-02 12:37:59.457033
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8bc411b5814e"
|
||||
down_revision: str | None = "0c3d1a5cff1f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"augmented_parts",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("sku", sa.String(), nullable=True),
|
||||
sa.Column("description", sa.String(), nullable=False),
|
||||
sa.Column("colour_id", sa.Integer(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("scope_model_id", sa.Integer(), nullable=True),
|
||||
sa.Column("part_type_id", sa.Integer(), nullable=False),
|
||||
sa.Column("sub_part_type_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["colour_id"], ["part_colours.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["scope_model_id"], ["scope_models.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["sub_part_type_id"], ["sub_part_types.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("augmented_parts")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_archived_to_augmented_parts
|
||||
|
||||
Revision ID: 9a1c2f6e0b3d
|
||||
Revises: 235d823a56ee
|
||||
Create Date: 2026-08-17 21:30:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9a1c2f6e0b3d"
|
||||
down_revision: str | None = "235d823a56ee"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"archived", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
|
||||
batch_op.drop_column("archived")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add_manufacturers_table
|
||||
|
||||
Revision ID: a192abbf4091
|
||||
Revises: 627d4460a94f
|
||||
Create Date: 2026-06-25 10:32:11.398529
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a192abbf4091"
|
||||
down_revision: str | None = "627d4460a94f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"manufacturers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=False),
|
||||
sa.Column("logo_url", sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# batch_alter_table's alter_column doesn't emit CREATE TYPE for the new
|
||||
# Enum on its own (unlike create_table, which ties enum creation to the
|
||||
# table-create DDL event) - create it explicitly first, or Postgres fails
|
||||
# with `type "uncseries" does not exist`. checkfirst / no-op on SQLite.
|
||||
uncseries = sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries")
|
||||
uncseries.create(op.get_bind(), checkfirst=True)
|
||||
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"series",
|
||||
existing_type=sa.VARCHAR(length=8),
|
||||
type_=uncseries,
|
||||
existing_nullable=False,
|
||||
postgresql_using="series::uncseries",
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"series",
|
||||
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||
type_=sa.VARCHAR(length=8),
|
||||
existing_nullable=False,
|
||||
postgresql_using="series::varchar(8)",
|
||||
)
|
||||
sa.Enum(name="uncseries").drop(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.drop_table("manufacturers")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_objective_lens_diameter_to_scope_infos
|
||||
|
||||
Revision ID: a3f7c19b2d40
|
||||
Revises: 8012c00b0c04
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a3f7c19b2d40"
|
||||
down_revision: str | None = "8012c00b0c04"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scope_infos",
|
||||
sa.Column("objective_lens_diameter_mm", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scope_infos", "objective_lens_diameter_mm")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""add_scope_models_table
|
||||
|
||||
Revision ID: a52e7ca7a925
|
||||
Revises: a192abbf4091
|
||||
Create Date: 2026-06-25 10:35:40.227253
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a52e7ca7a925"
|
||||
down_revision: str | None = "a192abbf4091"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"scope_models",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("scope_models")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add attachment_points
|
||||
|
||||
Revision ID: adf4a5b9493e
|
||||
Revises: 70e48bda8829
|
||||
Create Date: 2026-07-02 14:03:23.561253
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "adf4a5b9493e"
|
||||
down_revision: str | None = "70e48bda8829"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"attachment_points",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
|
||||
sa.Column("attachment_point_name_id", sa.Integer(), nullable=False),
|
||||
sa.Column("iso_thread_id", sa.Integer(), nullable=True),
|
||||
sa.Column("unc_thread_id", sa.Integer(), nullable=True),
|
||||
sa.Column("clamp_connection_id", sa.Integer(), nullable=True),
|
||||
sa.Column("static_connection_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["attachment_point_name_id"],
|
||||
["attachment_point_names.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["iso_thread_id"], ["iso_threads.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["unc_thread_id"], ["unc_threads.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["clamp_connection_id"], ["clamp_connections.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["static_connection_id"], ["static_connections.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("attachment_points")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add_sub_part_types_table
|
||||
|
||||
Revision ID: b60d0aa3ceda
|
||||
Revises: fd5379986da4
|
||||
Create Date: 2026-06-25 11:29:20.420056
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b60d0aa3ceda"
|
||||
down_revision: str | None = "fd5379986da4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sub_part_types",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("part_type_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["part_type_id"], ["part_types.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name", "part_type_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sub_part_types")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add augmented_part_imported_parts
|
||||
|
||||
Revision ID: bfbce324cd5f
|
||||
Revises: 8bc411b5814e
|
||||
Create Date: 2026-07-02 12:46:27.493942
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bfbce324cd5f"
|
||||
down_revision: str | None = "8bc411b5814e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"augmented_part_imported_parts",
|
||||
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
|
||||
sa.Column("imported_part_id", sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["imported_part_id"], ["imported_parts.InventoryID"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("augmented_part_id", "imported_part_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("augmented_part_imported_parts")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add clamp_connections
|
||||
|
||||
Revision ID: c7882cedc9ae
|
||||
Revises: bfbce324cd5f
|
||||
Create Date: 2026-07-02 13:31:57.064381
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c7882cedc9ae"
|
||||
down_revision: str | None = "bfbce324cd5f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"clamp_connections",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("min_mm", sa.Float(), nullable=True),
|
||||
sa.Column("max_mm", sa.Float(), nullable=True),
|
||||
sa.Column("min_in", sa.Float(), nullable=True),
|
||||
sa.Column("max_in", sa.Float(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("clamp_connections")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add LDAP user fields and default role flag
|
||||
|
||||
Revision ID: c9f1a2b3d4e5
|
||||
Revises: 1b99be8a9b1e
|
||||
Create Date: 2026-07-16 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c9f1a2b3d4e5"
|
||||
down_revision: str | None = "1b99be8a9b1e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Batch mode so SQLite can alter the nullability of hashed_password.
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(sa.Column("username", sa.String(length=255), nullable=True))
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"auth_source",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="local",
|
||||
)
|
||||
)
|
||||
batch.alter_column(
|
||||
"hashed_password", existing_type=sa.String(length=255), nullable=True
|
||||
)
|
||||
batch.create_index("ix_users_username", ["username"], unique=True)
|
||||
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column(
|
||||
"is_default", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("roles", "is_default")
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_username")
|
||||
batch.alter_column(
|
||||
"hashed_password", existing_type=sa.String(length=255), nullable=False
|
||||
)
|
||||
batch.drop_column("auth_source")
|
||||
batch.drop_column("username")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add_scope_infos_table
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: c9f1a2b3d4e5
|
||||
Create Date: 2026-07-16 20:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d1e2f3a4b5c6"
|
||||
down_revision: str | None = "c9f1a2b3d4e5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scope_infos",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
|
||||
sa.Column("magnification_min", sa.Float(), nullable=True),
|
||||
sa.Column("magnification_max", sa.Float(), nullable=True),
|
||||
sa.Column("model_suffix", sa.String(), nullable=True),
|
||||
sa.Column("objective_thread_pitch", sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("augmented_part_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("scope_infos")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add_part_types_table
|
||||
|
||||
Revision ID: e23aa8dd2536
|
||||
Revises: a52e7ca7a925
|
||||
Create Date: 2026-06-25 10:49:56.463466
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e23aa8dd2536"
|
||||
down_revision: str | None = "a52e7ca7a925"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"part_types",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("part_types")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add_audit_logs_table
|
||||
|
||||
Revision ID: fd5379986da4
|
||||
Revises: e23aa8dd2536
|
||||
Create Date: 2026-06-25 11:06:13.028067
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fd5379986da4"
|
||||
down_revision: str | None = "e23aa8dd2536"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("user_email", sa.String(length=255), nullable=False),
|
||||
sa.Column("action", sa.String(length=16), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("resource_id", sa.Integer(), nullable=True),
|
||||
sa.Column("description", sa.String(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
|
||||
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
|
||||
op.drop_table("audit_logs")
|
||||
@@ -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 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")),
|
||||
):
|
||||
if await apn_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Attachment point name already exists"
|
||||
)
|
||||
apn = await apn_crud.create(db, name=payload.name)
|
||||
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)
|
||||
if "name" in changes:
|
||||
existing = await apn_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != apn.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Attachment point name already exists"
|
||||
)
|
||||
apn = await apn_crud.update(db, apn, changes)
|
||||
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,135 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import attachment_point as attachment_point_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.attachment_point import (
|
||||
AttachmentPointCreate,
|
||||
AttachmentPointRead,
|
||||
AttachmentPointUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _connection_label(ap) -> str:
|
||||
if ap.iso_thread:
|
||||
return f"M{ap.iso_thread.diameter_mm}×{ap.iso_thread.pitch_mm}"
|
||||
if ap.unc_thread:
|
||||
return f"{ap.unc_thread.diameter_fraction}-{ap.unc_thread.tpi} {ap.unc_thread.series}"
|
||||
if ap.clamp_connection:
|
||||
cc = ap.clamp_connection
|
||||
if cc.min_mm is not None:
|
||||
return f"{cc.min_mm}–{cc.max_mm} mm"
|
||||
return f"{cc.min_in}–{cc.max_in} in"
|
||||
if ap.static_connection:
|
||||
return f"{ap.static_connection.od_mm} mm OD"
|
||||
return "Manual"
|
||||
|
||||
|
||||
@router.get("", response_model=list[AttachmentPointRead])
|
||||
async def list_attachment_points(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_points:read")),
|
||||
):
|
||||
return await attachment_point_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=AttachmentPointRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_attachment_point(
|
||||
payload: AttachmentPointCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:write")),
|
||||
):
|
||||
conn_ids = [
|
||||
payload.iso_thread_id,
|
||||
payload.unc_thread_id,
|
||||
payload.clamp_connection_id,
|
||||
payload.static_connection_id,
|
||||
]
|
||||
if sum(v is not None for v in conn_ids) > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"At most one connection (iso_thread, unc_thread, clamp_connection, "
|
||||
"or static_connection) may be provided. Leave all unset for a "
|
||||
"manual attachment point."
|
||||
),
|
||||
)
|
||||
ap = await attachment_point_crud.create(db, **payload.model_dump())
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_create(
|
||||
"AttachmentPoint", ap.id, _connection_label(ap)
|
||||
),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.get("/{attachment_point_id}", response_model=AttachmentPointRead)
|
||||
async def get_attachment_point(
|
||||
attachment_point_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_points:read")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
return ap
|
||||
|
||||
|
||||
@router.patch("/{attachment_point_id}", response_model=AttachmentPointRead)
|
||||
async def update_attachment_point(
|
||||
attachment_point_id: int,
|
||||
payload: AttachmentPointUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:write")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
ap = await attachment_point_crud.update(db, ap, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_update("AttachmentPoint", ap.id, changes),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.delete("/{attachment_point_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_attachment_point(
|
||||
attachment_point_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:delete")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
label = _connection_label(ap)
|
||||
await attachment_point_crud.delete(db, ap)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=attachment_point_id,
|
||||
description=audit_crud.describe_delete(
|
||||
"AttachmentPoint", attachment_point_id, label
|
||||
),
|
||||
)
|
||||
@@ -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,175 @@
|
||||
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()
|
||||
|
||||
|
||||
async def _get_augmented_part_by_identifier(db: AsyncSession, identifier: str):
|
||||
"""Resolve an AugmentedPart by numeric id, falling back to sku."""
|
||||
if identifier.isdigit():
|
||||
ap = await augmented_part_crud.get(db, int(identifier))
|
||||
if ap is not None:
|
||||
return ap
|
||||
return await augmented_part_crud.get_by_sku(db, identifier)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AugmentedPartRead])
|
||||
async def list_augmented_parts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
include_archived: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("augmented_parts:read")),
|
||||
):
|
||||
return await augmented_part_crud.list_all(
|
||||
db, skip=skip, limit=limit, include_archived=include_archived
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_augmented_part(
|
||||
payload: AugmentedPartCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
if payload.sku and await augmented_part_crud.get_by_sku(db, payload.sku):
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
ap = await augmented_part_crud.create(
|
||||
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,
|
||||
on_website=payload.on_website,
|
||||
preferred_part=payload.preferred_part,
|
||||
)
|
||||
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.updated} updated, "
|
||||
f"{result.archived} archived, {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("/{identifier}", response_model=AugmentedPartRead)
|
||||
async def update_augmented_part(
|
||||
identifier: str,
|
||||
payload: AugmentedPartUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
ap = await _get_augmented_part_by_identifier(db, identifier)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if changes.get("sku"):
|
||||
existing = await augmented_part_crud.get_by_sku(db, changes["sku"])
|
||||
if existing is not None and existing.id != ap.id:
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
ap = await augmented_part_crud.update(db, ap, changes)
|
||||
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("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_augmented_part(
|
||||
identifier: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:delete")),
|
||||
):
|
||||
ap = await _get_augmented_part_by_identifier(db, identifier)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
resolved_id = ap.id
|
||||
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=resolved_id,
|
||||
description=audit_crud.describe_delete("AugmentedPart", resolved_id, label),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{augmented_part_id}/notes",
|
||||
response_model=AugmentedPartNoteRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_augmented_part_note(
|
||||
augmented_part_id: int,
|
||||
payload: AugmentedPartNoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
return await augmented_part_note_crud.create(
|
||||
db, augmented_part=ap, author=actor, body=payload.body
|
||||
)
|
||||
+32
-3
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.crud import role as role_crud
|
||||
from app.crud import token as token_crud
|
||||
@@ -10,27 +11,34 @@ from app.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
|
||||
from app.schemas.user import RegisterRequest, UserRead
|
||||
from app.services import ldap as ldap_service
|
||||
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Fallback default role name if no role is flagged as default in the DB.
|
||||
DEFAULT_USER_ROLE = "user"
|
||||
|
||||
|
||||
async def _default_role_ids(db: AsyncSession) -> list[int]:
|
||||
role = await role_crud.get_default(db)
|
||||
if role is None:
|
||||
role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
|
||||
return [role.id] if role else []
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
if await user_crud.get_by_email(db, payload.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
|
||||
role_ids = [default_role.id] if default_role else []
|
||||
user = await user_crud.create(
|
||||
db,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
full_name=payload.full_name,
|
||||
role_ids=role_ids,
|
||||
role_ids=await _default_role_ids(db),
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -52,6 +60,27 @@ async def login(
|
||||
return Token(access_token=access, refresh_token=refresh)
|
||||
|
||||
|
||||
@router.post("/ldap-login", response_model=Token)
|
||||
async def ldap_login(
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Authenticate against LDAP, provisioning the local profile on first login."""
|
||||
if not settings.ldap_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="LDAP login is not enabled"
|
||||
)
|
||||
user = await ldap_service.login(db, form.username, form.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access, refresh = await issue_token_pair(db, user)
|
||||
return Token(access_token=access, refresh_token=refresh)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
|
||||
@@ -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,154 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import imported_part as imported_part_crud
|
||||
from app.crud import imported_part_config as config_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.imported_part import (
|
||||
ImportedPartConfigRead,
|
||||
ImportedPartConfigUpdate,
|
||||
ImportedPartRead,
|
||||
SyncResult,
|
||||
)
|
||||
from app.services import augmented_part_sync
|
||||
from app.services import odata as odata_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ImportedPartRead])
|
||||
async def list_imported_parts(
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:read")),
|
||||
):
|
||||
return await imported_part_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/config", response_model=ImportedPartConfigRead)
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
config = await config_crud.get(db)
|
||||
if config is None:
|
||||
return ImportedPartConfigRead(odata_url=None, username=None, has_password=False)
|
||||
return ImportedPartConfigRead(
|
||||
odata_url=config.odata_url,
|
||||
username=config.username,
|
||||
has_password=bool(config.password),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=ImportedPartConfigRead)
|
||||
async def update_config(
|
||||
payload: ImportedPartConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
current = await config_crud.get(db)
|
||||
# Keep existing password if not provided in the update
|
||||
new_password = (
|
||||
payload.password
|
||||
if payload.password is not None
|
||||
else (current.password if current else None)
|
||||
)
|
||||
config = await config_crud.upsert(
|
||||
db,
|
||||
odata_url=payload.odata_url,
|
||||
username=payload.username,
|
||||
password=new_password,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="ImportedPartConfig",
|
||||
resource_id=1,
|
||||
description="Updated ImportedPart OData configuration",
|
||||
)
|
||||
return ImportedPartConfigRead(
|
||||
odata_url=config.odata_url,
|
||||
username=config.username,
|
||||
has_password=bool(config.password),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=SyncResult)
|
||||
async def refresh_imported_parts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
config = await config_crud.get(db)
|
||||
if not config or not config.odata_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="OData URL is not configured.",
|
||||
)
|
||||
records = await odata_service.fetch(
|
||||
config.odata_url, config.username, config.password
|
||||
)
|
||||
created, updated = await imported_part_crud.upsert_bulk(db, records)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="ImportedPart",
|
||||
resource_id=None,
|
||||
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
|
||||
)
|
||||
return SyncResult(created=created, updated=updated, total=len(records))
|
||||
|
||||
|
||||
@router.get("/{inventory_id}", response_model=ImportedPartRead)
|
||||
async def get_imported_part(
|
||||
inventory_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:read")),
|
||||
):
|
||||
part = await imported_part_crud.get(db, inventory_id)
|
||||
if part is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
|
||||
)
|
||||
return part
|
||||
|
||||
|
||||
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_imported_part(
|
||||
inventory_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:delete")),
|
||||
):
|
||||
part = await imported_part_crud.get(db, inventory_id)
|
||||
if part is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
|
||||
)
|
||||
archived = await augmented_part_sync.delete_imported_part_and_archive_orphans(
|
||||
db, part
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="ImportedPart",
|
||||
resource_id=None,
|
||||
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
|
||||
)
|
||||
if archived:
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Archived {archived} augmented part(s) left with no remaining "
|
||||
f"imported parts after deleting {inventory_id!r}"
|
||||
),
|
||||
)
|
||||
+45
-8
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import iso_thread as iso_thread_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
@@ -24,14 +25,25 @@ async def list_iso_threads(
|
||||
async def create_iso_thread(
|
||||
payload: IsoThreadCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("iso_threads:write")),
|
||||
actor: User = Depends(require_permission("iso_threads:write")),
|
||||
):
|
||||
return await iso_thread_crud.create(
|
||||
thread = await iso_thread_crud.create(
|
||||
db,
|
||||
diameter_mm=payload.diameter_mm,
|
||||
pitch_mm=payload.pitch_mm,
|
||||
depth_mm=payload.depth_mm,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="IsoThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_create(
|
||||
"IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"
|
||||
),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
|
||||
@@ -42,7 +54,9 @@ async def get_iso_thread(
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@@ -51,21 +65,44 @@ async def update_iso_thread(
|
||||
iso_thread_id: int,
|
||||
payload: IsoThreadUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("iso_threads:write")),
|
||||
actor: User = Depends(require_permission("iso_threads:write")),
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
|
||||
return await iso_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
thread = await iso_thread_crud.update(db, thread, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="IsoThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_update("IsoThread", thread.id, changes),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_iso_thread(
|
||||
iso_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("iso_threads:delete")),
|
||||
actor: User = Depends(require_permission("iso_threads:delete")),
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
name = f"M{thread.diameter_mm}×{thread.pitch_mm}"
|
||||
await iso_thread_crud.delete(db, thread)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="IsoThread",
|
||||
resource_id=iso_thread_id,
|
||||
description=audit_crud.describe_delete("IsoThread", iso_thread_id, name),
|
||||
)
|
||||
@@ -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,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_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")),
|
||||
):
|
||||
if await part_colour_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(status_code=409, detail="Part colour already exists")
|
||||
pc = await part_colour_crud.create(db, name=payload.name)
|
||||
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)
|
||||
if "name" in changes:
|
||||
existing = await part_colour_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != pc.id:
|
||||
raise HTTPException(status_code=409, detail="Part colour already exists")
|
||||
pc = await part_colour_crud.update(db, pc, changes)
|
||||
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,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 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")),
|
||||
):
|
||||
if await part_type_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
|
||||
)
|
||||
pt = await part_type_crud.create(db, name=payload.name)
|
||||
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)
|
||||
if "name" in changes:
|
||||
existing = await part_type_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != pt.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
|
||||
)
|
||||
pt = await part_type_crud.update(db, pt, changes)
|
||||
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),
|
||||
)
|
||||
+40
-5
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import role as role_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
@@ -22,18 +23,27 @@ async def list_roles(
|
||||
async def create_role(
|
||||
payload: RoleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
if await role_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
|
||||
)
|
||||
return await role_crud.create(
|
||||
role = await role_crud.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
permission_ids=payload.permission_ids,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="Role",
|
||||
resource_id=role.id,
|
||||
description=audit_crud.describe_create("Role", role.id, role.name),
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=list[PermissionRead])
|
||||
@@ -63,25 +73,50 @@ async def update_role(
|
||||
role_id: int,
|
||||
payload: RoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
role = await role_crud.get(db, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes:
|
||||
existing = await role_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != role.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
|
||||
)
|
||||
role = await role_crud.update(db, role, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="Role",
|
||||
resource_id=role.id,
|
||||
description=audit_crud.describe_update("Role", role.id, changes),
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
role = await role_crud.get(db, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
name = role.name
|
||||
await role_crud.delete(db, role)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="Role",
|
||||
resource_id=role_id,
|
||||
description=audit_crud.describe_delete("Role", role_id, name),
|
||||
)
|
||||
+65
-3
@@ -1,10 +1,72 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import auth, iso_threads, roles, unc_threads, users
|
||||
from app.api import (
|
||||
attachment_point_names,
|
||||
attachment_points,
|
||||
audit_log,
|
||||
augmented_parts,
|
||||
auth,
|
||||
clamp_connections,
|
||||
fits,
|
||||
imported_parts,
|
||||
iso_threads,
|
||||
manufacturers,
|
||||
part_colours,
|
||||
part_types,
|
||||
roles,
|
||||
scope_import,
|
||||
scope_models,
|
||||
static_connections,
|
||||
sub_part_types,
|
||||
unc_threads,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
|
||||
api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso-threads"])
|
||||
api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"])
|
||||
api_router.include_router(
|
||||
iso_threads.router, prefix="/iso-threads", tags=["iso-threads"]
|
||||
)
|
||||
api_router.include_router(
|
||||
unc_threads.router, prefix="/unc-threads", tags=["unc-threads"]
|
||||
)
|
||||
api_router.include_router(
|
||||
manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]
|
||||
)
|
||||
api_router.include_router(
|
||||
scope_models.router, prefix="/scope-models", tags=["scope-models"]
|
||||
)
|
||||
api_router.include_router(
|
||||
part_colours.router, prefix="/part-colours", tags=["part-colours"]
|
||||
)
|
||||
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
|
||||
api_router.include_router(
|
||||
sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]
|
||||
)
|
||||
api_router.include_router(
|
||||
static_connections.router, prefix="/static-connections", tags=["static-connections"]
|
||||
)
|
||||
api_router.include_router(
|
||||
clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"]
|
||||
)
|
||||
api_router.include_router(
|
||||
attachment_point_names.router,
|
||||
prefix="/attachment-point-names",
|
||||
tags=["attachment-point-names"],
|
||||
)
|
||||
api_router.include_router(
|
||||
attachment_points.router, prefix="/attachment-points", tags=["attachment-points"]
|
||||
)
|
||||
api_router.include_router(
|
||||
imported_parts.router, prefix="/imported-parts", tags=["imported-parts"]
|
||||
)
|
||||
api_router.include_router(
|
||||
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
|
||||
)
|
||||
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
|
||||
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
|
||||
api_router.include_router(
|
||||
scope_import.router, prefix="/scope-import", tags=["scope-import"]
|
||||
)
|
||||
@@ -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,115 @@
|
||||
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")),
|
||||
):
|
||||
if await static_connection_crud.get_by_od_mm(db, payload.od_mm):
|
||||
raise HTTPException(status_code=409, detail="Static connection already exists")
|
||||
sc = await static_connection_crud.create(
|
||||
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)
|
||||
if "od_mm" in changes:
|
||||
existing = await static_connection_crud.get_by_od_mm(db, changes["od_mm"])
|
||||
if existing is not None and existing.id != sc.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Static connection already exists"
|
||||
)
|
||||
sc = await static_connection_crud.update(db, sc, changes)
|
||||
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,119 @@
|
||||
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")),
|
||||
):
|
||||
if await sub_part_type_crud.get_by_name_and_part_type(
|
||||
db, payload.name, payload.part_type_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Sub part type already exists")
|
||||
spt = await sub_part_type_crud.create(
|
||||
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)
|
||||
if "name" in changes or "part_type_id" in changes:
|
||||
name = changes.get("name", spt.name)
|
||||
part_type_id = changes.get("part_type_id", spt.part_type_id)
|
||||
existing = await sub_part_type_crud.get_by_name_and_part_type(
|
||||
db, name, part_type_id
|
||||
)
|
||||
if existing is not None and existing.id != spt.id:
|
||||
raise HTTPException(status_code=409, detail="Sub part type already exists")
|
||||
spt = await sub_part_type_crud.update(db, spt, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="SubPartType",
|
||||
resource_id=spt.id,
|
||||
description=audit_crud.describe_update("SubPartType", spt.id, changes),
|
||||
)
|
||||
return spt
|
||||
|
||||
|
||||
@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_sub_part_type(
|
||||
sub_part_type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("sub_part_types:delete")),
|
||||
):
|
||||
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||
if spt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||
)
|
||||
name = spt.name
|
||||
await sub_part_type_crud.delete(db, spt)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="SubPartType",
|
||||
resource_id=sub_part_type_id,
|
||||
description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name),
|
||||
)
|
||||
+47
-8
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import unc_thread as unc_thread_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
@@ -24,15 +25,28 @@ async def list_unc_threads(
|
||||
async def create_unc_thread(
|
||||
payload: UncThreadCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("unc_threads:write")),
|
||||
actor: User = Depends(require_permission("unc_threads:write")),
|
||||
):
|
||||
return await unc_thread_crud.create(
|
||||
thread = await unc_thread_crud.create(
|
||||
db,
|
||||
diameter_fraction=payload.diameter_fraction,
|
||||
tpi=payload.tpi,
|
||||
series=payload.series,
|
||||
depth_in=payload.depth_in,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="UncThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_create(
|
||||
"UncThread",
|
||||
thread.id,
|
||||
f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}",
|
||||
),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{unc_thread_id}", response_model=UncThreadRead)
|
||||
@@ -43,7 +57,9 @@ async def get_unc_thread(
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@@ -52,21 +68,44 @@ async def update_unc_thread(
|
||||
unc_thread_id: int,
|
||||
payload: UncThreadUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("unc_threads:write")),
|
||||
actor: User = Depends(require_permission("unc_threads:write")),
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
|
||||
return await unc_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
thread = await unc_thread_crud.update(db, thread, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="UncThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_update("UncThread", thread.id, changes),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_unc_thread(
|
||||
unc_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("unc_threads:delete")),
|
||||
actor: User = Depends(require_permission("unc_threads:delete")),
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"
|
||||
await unc_thread_crud.delete(db, thread)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="UncThread",
|
||||
resource_id=unc_thread_id,
|
||||
description=audit_crud.describe_delete("UncThread", unc_thread_id, name),
|
||||
)
|
||||
+48
-13
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.user import User
|
||||
@@ -29,13 +30,13 @@ async def list_users(
|
||||
async def create_user(
|
||||
payload: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:write")),
|
||||
actor: User = Depends(require_permission("users:write")),
|
||||
):
|
||||
if await user_crud.get_by_email(db, payload.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
return await user_crud.create(
|
||||
created = await user_crud.create(
|
||||
db,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
@@ -43,6 +44,15 @@ async def create_user(
|
||||
is_superuser=payload.is_superuser,
|
||||
role_ids=payload.role_ids,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="User",
|
||||
resource_id=created.id,
|
||||
description=audit_crud.describe_create("User", created.id, created.email),
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
@@ -51,12 +61,12 @@ async def get_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:read")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
return user
|
||||
return target
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
@@ -64,25 +74,50 @@ async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:write")),
|
||||
actor: User = Depends(require_permission("users:write")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "email" in changes:
|
||||
existing = await user_crud.get_by_email(db, changes["email"])
|
||||
if existing is not None and existing.id != target.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
target = await user_crud.update(db, target, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="User",
|
||||
resource_id=user_id,
|
||||
description=audit_crud.describe_update("User", user_id, changes),
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:delete")),
|
||||
actor: User = Depends(require_permission("users:delete")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
await user_crud.delete(db, user)
|
||||
email = target.email
|
||||
await user_crud.delete(db, target)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="User",
|
||||
resource_id=user_id,
|
||||
description=audit_crud.describe_delete("User", user_id, email),
|
||||
)
|
||||
+18
-1
@@ -33,9 +33,26 @@ class Settings(BaseSettings):
|
||||
database_url: str = "sqlite+aiosqlite:///./app.db"
|
||||
auto_create_tables: bool = True
|
||||
|
||||
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
|
||||
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
|
||||
# session_timeout_hours governs how long a browser login lasts before the
|
||||
# user is bounced back to /login; it's independent of the JSON API's
|
||||
# short-lived access_token_expire_minutes.
|
||||
cookie_name: str = "access_token"
|
||||
cookie_secure: bool = False
|
||||
session_timeout_hours: int = 24
|
||||
|
||||
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
|
||||
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
|
||||
# ``{username}`` substituted) and bound directly with their password.
|
||||
ldap_enabled: bool = False
|
||||
ldap_host: str = "localhost"
|
||||
ldap_port: int = 389
|
||||
ldap_use_ssl: bool = False
|
||||
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
|
||||
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
|
||||
ldap_email_domain: str = "example.com"
|
||||
# LDAP attribute read for the provisioned user's display name.
|
||||
ldap_full_name_attr: str = "cn"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
@@ -5,6 +5,7 @@ Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -21,6 +22,20 @@ class Base(DeclarativeBase):
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
|
||||
|
||||
if engine.sync_engine.dialect.name == "sqlite":
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
|
||||
"""WAL mode lets readers/writers avoid blocking each other; busy_timeout
|
||||
makes a connection wait for a lock instead of immediately raising
|
||||
"database is locked" (the pysqlite/aiosqlite default is 0ms).
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.close()
|
||||
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
|
||||
@@ -47,10 +47,12 @@ def _create_token(
|
||||
return token, payload
|
||||
|
||||
|
||||
def create_access_token(subject: str | int) -> str:
|
||||
def create_access_token(
|
||||
subject: str | int, expires_delta: timedelta | None = None
|
||||
) -> str:
|
||||
token, _ = _create_token(
|
||||
subject,
|
||||
timedelta(minutes=settings.access_token_expire_minutes),
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
|
||||
ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -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,289 @@
|
||||
from sqlalchemy import distinct, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.associations import augmented_part_imported_parts
|
||||
from app.models.augmented_part import AugmentedPart
|
||||
from app.models.imported_part import ImportedPart
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.sub_part_type import SubPartType
|
||||
|
||||
_RELATIONSHIPS = [
|
||||
"colour",
|
||||
"manufacturer",
|
||||
"scope_model",
|
||||
"part_type",
|
||||
"sub_part_type",
|
||||
"notes",
|
||||
]
|
||||
|
||||
# Sortable columns for the admin list, keyed by the query-string "sort" value.
|
||||
# Columns backed by a related table's name require an outer join (the FK may be
|
||||
# nullable, e.g. scope_model_id) so unrelated rows still appear, ordered last.
|
||||
SORT_COLUMNS = {
|
||||
"id": (AugmentedPart.id, None),
|
||||
"sku": (AugmentedPart.sku, None),
|
||||
"description": (AugmentedPart.description, None),
|
||||
"colour": (PartColour.name, (PartColour, AugmentedPart.colour_id == PartColour.id)),
|
||||
"manufacturer": (
|
||||
Manufacturer.name,
|
||||
(Manufacturer, AugmentedPart.manufacturer_id == Manufacturer.id),
|
||||
),
|
||||
"scope_model": (
|
||||
ScopeModel.name,
|
||||
(ScopeModel, AugmentedPart.scope_model_id == ScopeModel.id),
|
||||
),
|
||||
"part_type": (PartType.name, (PartType, AugmentedPart.part_type_id == PartType.id)),
|
||||
"sub_part_type": (
|
||||
SubPartType.name,
|
||||
(SubPartType, AugmentedPart.sub_part_type_id == SubPartType.id),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _apply_sort(q, sort: str, direction: str):
|
||||
column, join = SORT_COLUMNS.get(sort, SORT_COLUMNS["description"])
|
||||
if join is not None:
|
||||
table, cond = join
|
||||
q = q.outerjoin(table, cond)
|
||||
order = column.desc() if direction == "desc" else column.asc()
|
||||
# AugmentedPart.id as a tiebreaker keeps paginated ordering stable.
|
||||
return q.order_by(order, AugmentedPart.id)
|
||||
|
||||
|
||||
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
|
||||
return await db.get(AugmentedPart, augmented_part_id)
|
||||
|
||||
|
||||
async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
|
||||
result = await db.execute(
|
||||
select(AugmentedPart).where(AugmentedPart.sku == sku).order_by(AugmentedPart.id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
include_archived: bool = False,
|
||||
) -> list[AugmentedPart]:
|
||||
q = select(AugmentedPart).order_by(AugmentedPart.description)
|
||||
if not include_archived:
|
||||
q = q.where(AugmentedPart.archived.is_(False))
|
||||
result = await db.execute(q.offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_many(db: AsyncSession, ids: list[int]) -> list[AugmentedPart]:
|
||||
"""Fetch augmented parts by id in one query (selectin relationships loaded)."""
|
||||
if not ids:
|
||||
return []
|
||||
result = await db.execute(select(AugmentedPart).where(AugmentedPart.id.in_(ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
colour_id: int | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
scope_model_id: int | None = None,
|
||||
part_type_id: int | None = None,
|
||||
sub_part_type_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
sort: str = "description",
|
||||
direction: str = "asc",
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[AugmentedPart]:
|
||||
q = select(AugmentedPart)
|
||||
if description.strip():
|
||||
term = f"%{description.strip()}%"
|
||||
q = q.where(
|
||||
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
|
||||
)
|
||||
if colour_id is not None:
|
||||
q = q.where(AugmentedPart.colour_id == colour_id)
|
||||
if manufacturer_id is not None:
|
||||
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
|
||||
if scope_model_id is not None:
|
||||
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
|
||||
if part_type_id is not None:
|
||||
q = q.where(AugmentedPart.part_type_id == part_type_id)
|
||||
if sub_part_type_id is not None:
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
if not include_archived:
|
||||
q = q.where(AugmentedPart.archived.is_(False))
|
||||
if on_website:
|
||||
q = q.where(AugmentedPart.on_website.is_(True))
|
||||
q = _apply_sort(q, sort, direction)
|
||||
result = await db.execute(q.offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
colour_id: int | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
scope_model_id: int | None = None,
|
||||
part_type_id: int | None = None,
|
||||
sub_part_type_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
q = select(func.count()).select_from(AugmentedPart)
|
||||
if description.strip():
|
||||
term = f"%{description.strip()}%"
|
||||
q = q.where(
|
||||
or_(AugmentedPart.description.ilike(term), AugmentedPart.sku.ilike(term))
|
||||
)
|
||||
if colour_id is not None:
|
||||
q = q.where(AugmentedPart.colour_id == colour_id)
|
||||
if manufacturer_id is not None:
|
||||
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
|
||||
if scope_model_id is not None:
|
||||
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
|
||||
if part_type_id is not None:
|
||||
q = q.where(AugmentedPart.part_type_id == part_type_id)
|
||||
if sub_part_type_id is not None:
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
if not include_archived:
|
||||
q = q.where(AugmentedPart.archived.is_(False))
|
||||
if on_website:
|
||||
q = q.where(AugmentedPart.on_website.is_(True))
|
||||
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,
|
||||
on_website: bool = False,
|
||||
preferred_part: bool = False,
|
||||
) -> 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,
|
||||
on_website=on_website,
|
||||
preferred_part=preferred_part,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
|
||||
return ap
|
||||
|
||||
|
||||
async def update(db: AsyncSession, ap: AugmentedPart, data: dict) -> AugmentedPart:
|
||||
for field, value in data.items():
|
||||
setattr(ap, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
|
||||
return ap
|
||||
|
||||
|
||||
async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
|
||||
"""ImportedParts that have no corresponding AugmentedPart link."""
|
||||
result = await db.execute(
|
||||
select(ImportedPart)
|
||||
.where(
|
||||
ImportedPart.InventoryID.not_in(
|
||||
select(augmented_part_imported_parts.c.imported_part_id)
|
||||
)
|
||||
)
|
||||
.order_by(ImportedPart.InventoryID)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def set_archived(
|
||||
db: AsyncSession, ap: AugmentedPart, archived: bool
|
||||
) -> AugmentedPart:
|
||||
ap.archived = archived
|
||||
await db.commit()
|
||||
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
|
||||
return ap
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
|
||||
await db.delete(ap)
|
||||
await db.commit()
|
||||
@@ -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,61 @@
|
||||
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 get_by_values(
|
||||
db: AsyncSession, *, min_mm: float | None, max_mm: float | None
|
||||
) -> ClampConnection | None:
|
||||
result = await db.execute(
|
||||
select(ClampConnection).where(
|
||||
ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create(
|
||||
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,153 @@
|
||||
from sqlalchemy import distinct, insert, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imported_part import ImportedPart
|
||||
|
||||
_FIELDS = frozenset(
|
||||
{
|
||||
"InventoryID",
|
||||
"Description",
|
||||
"TBXColour",
|
||||
"ProductMaxThreadDepthmm",
|
||||
"ProductMaxThreadDepthin",
|
||||
"ProductODmm",
|
||||
"ProductODin",
|
||||
"TBXThreadAttribute",
|
||||
"UACDiameterRangemm",
|
||||
}
|
||||
)
|
||||
|
||||
# Sortable columns for the admin list, keyed by the query-string "sort" value.
|
||||
SORT_COLUMNS = {
|
||||
"InventoryID": ImportedPart.InventoryID,
|
||||
"Description": ImportedPart.Description,
|
||||
"TBXColour": ImportedPart.TBXColour,
|
||||
"ProductMaxThreadDepthmm": ImportedPart.ProductMaxThreadDepthmm,
|
||||
"ProductMaxThreadDepthin": ImportedPart.ProductMaxThreadDepthin,
|
||||
"ProductODmm": ImportedPart.ProductODmm,
|
||||
"ProductODin": ImportedPart.ProductODin,
|
||||
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
|
||||
"UACDiameterRangemm": ImportedPart.UACDiameterRangemm,
|
||||
}
|
||||
|
||||
|
||||
def _apply_sort(q, sort: str, direction: str):
|
||||
column = SORT_COLUMNS.get(sort, ImportedPart.InventoryID)
|
||||
order = column.desc() if direction == "desc" else column.asc()
|
||||
return q.order_by(order, ImportedPart.InventoryID)
|
||||
|
||||
|
||||
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
|
||||
return await db.get(ImportedPart, inventory_id)
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 500
|
||||
) -> list[ImportedPart]:
|
||||
result = await db.execute(
|
||||
select(ImportedPart)
|
||||
.order_by(ImportedPart.InventoryID)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
sort: str = "InventoryID",
|
||||
direction: str = "asc",
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
) -> list[ImportedPart]:
|
||||
q = select(ImportedPart)
|
||||
if description.strip():
|
||||
term = f"%{description.strip()}%"
|
||||
q = q.where(
|
||||
or_(
|
||||
ImportedPart.Description.ilike(term),
|
||||
ImportedPart.InventoryID.ilike(term),
|
||||
)
|
||||
)
|
||||
q = _apply_sort(q, sort, direction)
|
||||
result = await db.execute(q.offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def distinct_colours(db: AsyncSession) -> list[str]:
|
||||
result = await db.execute(
|
||||
select(distinct(ImportedPart.TBXColour))
|
||||
.where(ImportedPart.TBXColour.isnot(None))
|
||||
.order_by(ImportedPart.TBXColour)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def count(db: AsyncSession) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
result = await db.execute(select(func.count()).select_from(ImportedPart))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def count_filtered(db: AsyncSession, description: str = "") -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
q = select(func.count()).select_from(ImportedPart)
|
||||
if description.strip():
|
||||
term = f"%{description.strip()}%"
|
||||
q = q.where(
|
||||
or_(
|
||||
ImportedPart.Description.ilike(term),
|
||||
ImportedPart.InventoryID.ilike(term),
|
||||
)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
|
||||
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
|
||||
if not records:
|
||||
return 0, 0
|
||||
|
||||
clean = [
|
||||
{
|
||||
k: str(v).strip() if v is not None else None
|
||||
for k, v in r.items()
|
||||
if k in _FIELDS
|
||||
}
|
||||
for r in records
|
||||
if r.get("InventoryID")
|
||||
]
|
||||
if not clean:
|
||||
return 0, 0
|
||||
|
||||
incoming_ids = {r["InventoryID"] for r in clean}
|
||||
|
||||
result = await db.execute(
|
||||
select(ImportedPart.InventoryID).where(
|
||||
ImportedPart.InventoryID.in_(incoming_ids)
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in result.all()}
|
||||
|
||||
to_insert = [r for r in clean if r["InventoryID"] not in existing_ids]
|
||||
to_update = [r for r in clean if r["InventoryID"] in existing_ids]
|
||||
|
||||
if to_insert:
|
||||
await db.execute(insert(ImportedPart), to_insert)
|
||||
|
||||
for record in to_update:
|
||||
inv_id = record["InventoryID"]
|
||||
vals = {k: v for k, v in record.items() if k != "InventoryID"}
|
||||
if vals:
|
||||
await db.execute(
|
||||
update(ImportedPart)
|
||||
.where(ImportedPart.InventoryID == inv_id)
|
||||
.values(vals)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return len(to_insert), len(to_update)
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imported_part_config import ImportedPartConfig
|
||||
|
||||
_SINGLETON_ID = 1
|
||||
|
||||
|
||||
async def get(db: AsyncSession) -> ImportedPartConfig | None:
|
||||
return await db.get(ImportedPartConfig, _SINGLETON_ID)
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
odata_url: str | None,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
) -> ImportedPartConfig:
|
||||
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
|
||||
if config is None:
|
||||
config = ImportedPartConfig(
|
||||
id=_SINGLETON_ID,
|
||||
odata_url=odata_url,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
config.odata_url = odata_url
|
||||
config.username = username
|
||||
config.password = password
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
+25
-1
@@ -8,7 +8,31 @@ async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
|
||||
return await db.get(IsoThread, iso_thread_id)
|
||||
|
||||
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]:
|
||||
async def get_by_values(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
pitch_mm: float,
|
||||
depth_mm: float | None,
|
||||
) -> IsoThread | None:
|
||||
depth_clause = (
|
||||
IsoThread.depth_mm.is_(None)
|
||||
if depth_mm is None
|
||||
else IsoThread.depth_mm == depth_mm
|
||||
)
|
||||
result = await db.execute(
|
||||
select(IsoThread).where(
|
||||
IsoThread.diameter_mm == diameter_mm,
|
||||
IsoThread.pitch_mm == pitch_mm,
|
||||
depth_clause,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[IsoThread]:
|
||||
result = await db.execute(
|
||||
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
|
||||
)
|
||||
|
||||
@@ -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 | None = None,
|
||||
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 update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.permission import Permission
|
||||
@@ -14,6 +15,21 @@ async def get_by_name(db: AsyncSession, name: str) -> Role | None:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_default(db: AsyncSession) -> Role | None:
|
||||
"""The role auto-assigned to new users, or ``None`` if none is marked."""
|
||||
result = await db.execute(select(Role).where(Role.is_default.is_(True)))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def set_default(db: AsyncSession, role: Role) -> Role:
|
||||
"""Make ``role`` the sole default, clearing the flag on every other role."""
|
||||
await db.execute(sa_update(Role).where(Role.id != role.id).values(is_default=False))
|
||||
role.is_default = True
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
async def list_roles(db: AsyncSession) -> list[Role]:
|
||||
result = await db.execute(select(Role).order_by(Role.id))
|
||||
return list(result.scalars().all())
|
||||
@@ -50,6 +66,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
|
||||
else:
|
||||
data.pop("permission_ids", None)
|
||||
|
||||
# Setting a role default must clear the flag on all others.
|
||||
make_default = data.pop("is_default", None)
|
||||
if make_default:
|
||||
await db.execute(
|
||||
sa_update(Role).where(Role.id != role.id).values(is_default=False)
|
||||
)
|
||||
role.is_default = True
|
||||
elif make_default is False:
|
||||
role.is_default = False
|
||||
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(role, field, value)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.scope_info import ScopeInfo
|
||||
|
||||
|
||||
async def get(db: AsyncSession, scope_info_id: int) -> ScopeInfo | None:
|
||||
return await db.get(ScopeInfo, scope_info_id)
|
||||
|
||||
|
||||
async def get_by_augmented_part(
|
||||
db: AsyncSession, augmented_part_id: int
|
||||
) -> ScopeInfo | None:
|
||||
result = await db.execute(
|
||||
select(ScopeInfo).where(ScopeInfo.augmented_part_id == augmented_part_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_by_augmented_parts(
|
||||
db: AsyncSession, augmented_part_ids: list[int]
|
||||
) -> list[ScopeInfo]:
|
||||
"""ScopeInfo rows for the given augmented parts, fetched in one query."""
|
||||
if not augmented_part_ids:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(ScopeInfo).where(ScopeInfo.augmented_part_id.in_(augmented_part_ids))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
augmented_part_id: int,
|
||||
magnification_min: float | None = None,
|
||||
magnification_max: float | None = None,
|
||||
model_suffix: str | None = None,
|
||||
objective_lens_diameter_mm: float | None = None,
|
||||
) -> ScopeInfo:
|
||||
info = ScopeInfo(
|
||||
augmented_part_id=augmented_part_id,
|
||||
magnification_min=magnification_min,
|
||||
magnification_max=magnification_max,
|
||||
model_suffix=model_suffix,
|
||||
objective_lens_diameter_mm=objective_lens_diameter_mm,
|
||||
)
|
||||
db.add(info)
|
||||
await db.commit()
|
||||
await db.refresh(info)
|
||||
return info
|
||||
|
||||
|
||||
async def upsert(db: AsyncSession, *, augmented_part_id: int, **fields) -> ScopeInfo:
|
||||
"""Create the ScopeInfo for an AugmentedPart, or update it if it exists."""
|
||||
info = await get_by_augmented_part(db, augmented_part_id)
|
||||
if info is None:
|
||||
return await create(db, augmented_part_id=augmented_part_id, **fields)
|
||||
for field, value in fields.items():
|
||||
setattr(info, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(info)
|
||||
return info
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, info: ScopeInfo) -> None:
|
||||
await db.delete(info)
|
||||
await db.commit()
|
||||
@@ -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()
|
||||
+27
-1
@@ -8,7 +8,33 @@ async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
|
||||
return await db.get(UncThread, unc_thread_id)
|
||||
|
||||
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]:
|
||||
async def get_by_values(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_fraction: str,
|
||||
tpi: int,
|
||||
series: UncSeries,
|
||||
depth_in: float | None,
|
||||
) -> UncThread | None:
|
||||
depth_clause = (
|
||||
UncThread.depth_in.is_(None)
|
||||
if depth_in is None
|
||||
else UncThread.depth_in == depth_in
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UncThread).where(
|
||||
UncThread.diameter_fraction == diameter_fraction,
|
||||
UncThread.tpi == tpi,
|
||||
UncThread.series == series,
|
||||
depth_clause,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[UncThread]:
|
||||
result = await db.execute(
|
||||
select(UncThread).order_by(UncThread.id).offset(skip).limit(limit)
|
||||
)
|
||||
|
||||
+47
-4
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.role import Role
|
||||
from app.models.user import User
|
||||
from app.models.user import AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User
|
||||
|
||||
|
||||
async def get(db: AsyncSession, user_id: int) -> User | None:
|
||||
@@ -15,6 +15,11 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
|
||||
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
@@ -36,14 +41,18 @@ async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
password: str | None = None,
|
||||
full_name: str | None = None,
|
||||
is_superuser: bool = False,
|
||||
role_ids: list[int] | None = None,
|
||||
username: str | None = None,
|
||||
auth_source: str = AUTH_SOURCE_LOCAL,
|
||||
) -> User:
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=hash_password(password),
|
||||
username=username,
|
||||
hashed_password=hash_password(password) if password else None,
|
||||
auth_source=auth_source,
|
||||
full_name=full_name,
|
||||
is_superuser=is_superuser,
|
||||
roles=await _resolve_roles(db, role_ids or []),
|
||||
@@ -54,6 +63,37 @@ async def create(
|
||||
return user
|
||||
|
||||
|
||||
async def get_or_create_ldap_user(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
username: str,
|
||||
email: str,
|
||||
full_name: str | None,
|
||||
role_ids: list[int] | None = None,
|
||||
) -> User:
|
||||
"""Find an LDAP user by username, provisioning a local profile on first login.
|
||||
|
||||
The profile is keyed on the LDAP username; email is synthesized by the
|
||||
caller. Newly provisioned users get ``role_ids`` (the default role).
|
||||
"""
|
||||
user = await get_by_username(db, username)
|
||||
if user is not None:
|
||||
# Keep the display name in sync with LDAP on subsequent logins.
|
||||
if full_name and user.full_name != full_name:
|
||||
user.full_name = full_name
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
return await create(
|
||||
db,
|
||||
email=email,
|
||||
username=username,
|
||||
full_name=full_name,
|
||||
auth_source=AUTH_SOURCE_LDAP,
|
||||
role_ids=role_ids,
|
||||
)
|
||||
|
||||
|
||||
async def update(db: AsyncSession, user: User, data: dict) -> User:
|
||||
if "password" in data and data["password"]:
|
||||
user.hashed_password = hash_password(data.pop("password"))
|
||||
@@ -81,6 +121,9 @@ async def delete(db: AsyncSession, user: User) -> None:
|
||||
|
||||
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
|
||||
user = await get_by_email(db, email)
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
# LDAP users have no local password and must sign in via LDAP.
|
||||
if not user or not user.hashed_password:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
+12
-1
@@ -1,5 +1,6 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -12,6 +13,7 @@ from app.core.database import init_db
|
||||
from app.deps import NotAuthenticated
|
||||
from app.web.admin import router as admin_router
|
||||
from app.web.pages import router as pages_router
|
||||
from app.web.scopes import router as scopes_router
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
@@ -39,11 +41,20 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
app.include_router(pages_router)
|
||||
app.include_router(scopes_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@app.exception_handler(NotAuthenticated)
|
||||
async def _redirect_to_login(request: Request, exc: NotAuthenticated):
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
# Preserve the originally requested URL so we can return there after
|
||||
# login. Only meaningful for GET navigations (the ones users deep-link).
|
||||
login_url = "/login"
|
||||
if request.method == "GET" and request.url.path != "/login":
|
||||
target = request.url.path
|
||||
if request.url.query:
|
||||
target += "?" + request.url.query
|
||||
login_url = f"/login?next={quote(target, safe='')}"
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
|
||||
@app.get("/healthz", tags=["health"])
|
||||
async def healthz():
|
||||
|
||||
+41
-1
@@ -1,20 +1,60 @@
|
||||
"""Import all models so they register with ``Base.metadata``."""
|
||||
|
||||
from app.models.associations import role_permissions, user_roles
|
||||
from app.models.associations import (
|
||||
augmented_part_imported_parts,
|
||||
role_permissions,
|
||||
user_roles,
|
||||
)
|
||||
from app.models.attachment_point import AttachmentPoint
|
||||
from app.models.attachment_point_name import AttachmentPointName
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.augmented_part import AugmentedPart, AugmentedPartNote
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
from app.models.fit import Fit, FitLabel, FitNote
|
||||
from app.models.imported_part import ImportedPart
|
||||
from app.models.imported_part_config import ImportedPartConfig
|
||||
from app.models.iso_thread import IsoThread
|
||||
from app.models.job import Job
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
from app.models.permission import Permission
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.role import Role
|
||||
from app.models.scope_info import ScopeInfo
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.static_connection import StaticConnection
|
||||
from app.models.sub_part_type import SubPartType
|
||||
from app.models.unc_thread import UncThread
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"AttachmentPoint",
|
||||
"AttachmentPointName",
|
||||
"AuditLog",
|
||||
"AugmentedPart",
|
||||
"AugmentedPartNote",
|
||||
"ClampConnection",
|
||||
"Fit",
|
||||
"FitLabel",
|
||||
"FitNote",
|
||||
"ImportedPart",
|
||||
"ImportedPartConfig",
|
||||
"IsoThread",
|
||||
"Job",
|
||||
"Manufacturer",
|
||||
"PartColour",
|
||||
"PartType",
|
||||
"Permission",
|
||||
"ScopeInfo",
|
||||
"ScopeModel",
|
||||
"StaticConnection",
|
||||
"SubPartType",
|
||||
"RefreshToken",
|
||||
"Role",
|
||||
"UncThread",
|
||||
"User",
|
||||
"augmented_part_imported_parts",
|
||||
"role_permissions",
|
||||
"user_roles",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Many-to-many association tables for RBAC."""
|
||||
"""Many-to-many association tables."""
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Table
|
||||
from sqlalchemy import Column, ForeignKey, String, Table
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -21,3 +21,19 @@ role_permissions = Table(
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
augmented_part_imported_parts = Table(
|
||||
"augmented_part_imported_parts",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"augmented_part_id",
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"imported_part_id",
|
||||
String,
|
||||
ForeignKey("imported_parts.InventoryID", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.attachment_point_name import AttachmentPointName
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
from app.models.iso_thread import IsoThread
|
||||
from app.models.static_connection import StaticConnection
|
||||
from app.models.unc_thread import UncThread
|
||||
|
||||
|
||||
class AttachmentPoint(Base):
|
||||
__tablename__ = "attachment_points"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
augmented_part_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
attachment_point_name_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
# At most one of the four below is non-null (enforced in application layer).
|
||||
# All four null means a "Manual" attachment point: no connection details,
|
||||
# used to let a user assert a Fit by hand without the fit-matching algorithm
|
||||
# (app.services.fit_matching) ever auto-matching it.
|
||||
iso_thread_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
|
||||
)
|
||||
unc_thread_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("unc_threads.id", ondelete="RESTRICT"), nullable=True, default=None
|
||||
)
|
||||
clamp_connection_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("clamp_connections.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
static_connection_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("static_connections.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
attachment_point_name: Mapped[AttachmentPointName] = relationship(lazy="selectin")
|
||||
iso_thread: Mapped[IsoThread | None] = relationship(lazy="selectin")
|
||||
unc_thread: Mapped[UncThread | None] = relationship(lazy="selectin")
|
||||
clamp_connection: Mapped[ClampConnection | None] = relationship(lazy="selectin")
|
||||
static_connection: Mapped[StaticConnection | None] = relationship(lazy="selectin")
|
||||
@@ -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,89 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.associations import augmented_part_imported_parts
|
||||
from app.models.imported_part import ImportedPart
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.sub_part_type import SubPartType
|
||||
|
||||
|
||||
class AugmentedPart(Base):
|
||||
__tablename__ = "augmented_parts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
sku: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, unique=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,
|
||||
)
|
||||
# "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync
|
||||
# service. Read-only — never set outside app/services/augmented_part_sync.py.
|
||||
uac_diameter_range_mm: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, default=None
|
||||
)
|
||||
# Set instead of deleting the row when the last backing ImportedPart is
|
||||
# removed, so fit/attachment-point history is preserved. Hidden from list
|
||||
# views by default.
|
||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
on_website: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
preferred_part: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# linked imported parts — not eagerly loaded; used by sync service only
|
||||
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(enum.StrEnum):
|
||||
POSSIBLE = "possible"
|
||||
EXACT = "exact"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class Fit(Base):
|
||||
"""A candidate or confirmed fit between two attachment points on different parts."""
|
||||
|
||||
__tablename__ = "fits"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Canonical ordering (attachment_point_a_id < attachment_point_b_id) is enforced
|
||||
# by app.crud.fit so an unordered pair only ever has one row.
|
||||
attachment_point_a_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
attachment_point_b_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Denormalized from the attachment points' augmented_part_id, for querying
|
||||
# "fits for this part" without joining through attachment_points.
|
||||
augmented_part_a_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
augmented_part_b_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
label: Mapped[FitLabel] = mapped_column(
|
||||
Enum(FitLabel, values_callable=lambda enum_cls: [e.value for e in enum_cls]),
|
||||
nullable=False,
|
||||
default=FitLabel.POSSIBLE,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
attachment_point_a = relationship(
|
||||
"AttachmentPoint", foreign_keys=[attachment_point_a_id], lazy="selectin"
|
||||
)
|
||||
attachment_point_b = relationship(
|
||||
"AttachmentPoint", foreign_keys=[attachment_point_b_id], lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list["FitNote"]] = relationship(
|
||||
back_populates="fit",
|
||||
lazy="selectin",
|
||||
order_by="FitNote.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class FitNote(Base):
|
||||
__tablename__ = "fit_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
fit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("fits.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Denormalized snapshot of who wrote the note (mirrors AuditLog.user_email),
|
||||
# so the note stays attributable even if the user is later deleted.
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
fit: Mapped[Fit] = relationship(back_populates="notes")
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
UACDiameterRangemm: 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,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Job(Base):
|
||||
"""Tracks a long-running admin operation (sync, import, fit recompute).
|
||||
|
||||
Persisted (rather than kept in a module-level dict) so that any worker
|
||||
process/replica handling a status-poll request can see progress recorded
|
||||
by whichever process is actually running the job.
|
||||
"""
|
||||
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running"
|
||||
) # running | done | error
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False, default="Working…")
|
||||
result: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
@@ -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 | None] = mapped_column(nullable=True)
|
||||
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 app.core.database import Base
|
||||
@@ -11,6 +11,9 @@ class Role(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
# The role auto-assigned to newly provisioned/registered users. At most one
|
||||
# role is the default; see ``crud.role.set_default``.
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
users: Mapped[list["User"]] = relationship( # noqa: F821
|
||||
secondary=user_roles,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Float, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ScopeInfo(Base):
|
||||
"""Scope-specific attributes for an AugmentedPart of PartType "Scope".
|
||||
|
||||
One-to-one with AugmentedPart (unique augmented_part_id). Holds fields that
|
||||
have no home on AugmentedPart itself; populated by the scope xlsx importer.
|
||||
"""
|
||||
|
||||
__tablename__ = "scope_infos"
|
||||
__table_args__ = (UniqueConstraint("augmented_part_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
augmented_part_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
magnification_min: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True, default=None
|
||||
)
|
||||
magnification_max: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True, default=None
|
||||
)
|
||||
model_suffix: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, default=None
|
||||
)
|
||||
# The lens diameter is a descriptive attribute (the "50" in "3-18x50"), not a
|
||||
# connection dimension — it has no AttachmentPoint.
|
||||
objective_lens_diameter_mm: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True, default=None
|
||||
)
|
||||
@@ -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)
|
||||
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user