Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6dc6dd6b8 | ||
|
|
aca15db9bf | ||
|
|
a0bd7f2c27 | ||
|
|
0150c19b75 | ||
|
|
1180694738 | ||
|
|
6d20db75c5 | ||
|
|
69cbfa2d3f | ||
|
|
cda15df2ed | ||
|
|
ab39191af5 | ||
|
|
8185f1f8b3 | ||
|
|
7bd07f084a | ||
|
|
87ba8cd169 | ||
|
|
c5d1186b95 | ||
|
|
4785fd5f87 | ||
|
|
e923250177 | ||
|
|
a50fccd56d | ||
|
|
d67b4ceb65 | ||
|
|
ceae27a12e | ||
|
|
2767fb1c8e | ||
|
|
ae50ec7049 | ||
|
|
7fcf5b3f75 | ||
|
|
59184124e7 | ||
|
|
0168b023af | ||
|
|
87019ff2af | ||
|
|
c320595423 | ||
|
|
00fb43c8ed | ||
|
|
2e7002b73b | ||
|
|
422e217180 | ||
|
|
eba7896893 | ||
|
|
1549971a13 | ||
|
|
291416544e | ||
|
|
c8696e67b5 | ||
|
|
45bc5bf8f6 | ||
|
|
fe6f9cca05 | ||
|
|
b7ad517174 | ||
|
|
9111117404 | ||
|
|
78efe32b69 | ||
|
|
35e8a61b1d | ||
|
|
9285a021c0 | ||
|
|
a751e9b680 | ||
|
|
a3f686ab3b |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Copy to .env and adjust. Every value here can also be supplied as a real
|
||||
# environment variable (which takes precedence over the .env file).
|
||||
|
||||
# --- General ---
|
||||
APP_NAME="Armament FitGuide"
|
||||
DEBUG=true
|
||||
|
||||
# --- Security ---
|
||||
# Generate a strong value, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
|
||||
SECRET_KEY="change-me-please-use-a-long-random-string"
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# --- Database ---
|
||||
# SQLite (zero-setup default):
|
||||
DATABASE_URL="sqlite+aiosqlite:///./app.db"
|
||||
# Postgres example:
|
||||
# DATABASE_URL="postgresql+asyncpg://app:app@localhost:5432/app"
|
||||
|
||||
# Create tables on startup (handy for dev/SQLite; use Alembic in production).
|
||||
AUTO_CREATE_TABLES=true
|
||||
|
||||
# --- Web / cookies ---
|
||||
COOKIE_NAME=access_token
|
||||
# Set true when serving over HTTPS.
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# --- LDAP (optional; direct-bind) ---
|
||||
# When enabled, the login page and /api/auth/ldap-login accept a directory
|
||||
# username + password. On first login a local profile is provisioned using the
|
||||
# username and the default role (mark one in the Roles admin UI).
|
||||
LDAP_ENABLED=true
|
||||
LDAP_HOST=192.168.110.10
|
||||
LDAP_PORT=636
|
||||
LDAP_USE_SSL=true
|
||||
# "{username}" is substituted with the submitted username to form the bind DN.
|
||||
LDAP_BIND_DN_TEMPLATE="CN={username},CN=Users,DC=tenarmtan,DC=com"
|
||||
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
|
||||
LDAP_EMAIL_DOMAIN=armament.com
|
||||
# LDAP attribute read for the provisioned user's display name.
|
||||
LDAP_FULL_NAME_ATTR=cn
|
||||
|
||||
# --- CORS (JSON list) ---
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
# --- Bootstrap superuser (used by scripts/seed.py) ---
|
||||
FIRST_SUPERUSER_EMAIL=admin@armament.com
|
||||
FIRST_SUPERUSER_PASSWORD=changethis
|
||||
+16
-27
@@ -2,7 +2,7 @@
|
||||
# environment variable (which takes precedence over the .env file).
|
||||
|
||||
# --- General ---
|
||||
APP_NAME="FastAPI Skeleton"
|
||||
APP_NAME="Armament FitGuide"
|
||||
DEBUG=false
|
||||
|
||||
# --- Security ---
|
||||
@@ -26,35 +26,24 @@ 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=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=["*"]
|
||||
|
||||
# --- LDAP / Active Directory (optional) ---
|
||||
# When enabled, login credentials are validated against the directory and a
|
||||
# local user is provisioned on first login. Local DB auth still works as a
|
||||
# fallback (so the seeded superuser can log in). Disabled by default.
|
||||
LDAP_ENABLED=false
|
||||
LDAP_SERVER_URI="ldap://ldap.example.com:389"
|
||||
LDAP_USE_SSL=false
|
||||
LDAP_START_TLS=false
|
||||
LDAP_TLS_VALIDATE=true
|
||||
# LDAP_CA_CERTS_FILE="/etc/ssl/certs/ca.pem"
|
||||
|
||||
# Option 1 — direct bind: build the user DN from the login name.
|
||||
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
|
||||
|
||||
# Option 2 — search + bind: a service account finds the user, then we bind as
|
||||
# the found DN. Leave the template above empty to use this mode.
|
||||
# LDAP_BIND_DN="cn=svc-reader,dc=example,dc=com"
|
||||
# LDAP_BIND_PASSWORD="service-account-password"
|
||||
# LDAP_USER_SEARCH_BASE="ou=people,dc=example,dc=com"
|
||||
# LDAP_USER_SEARCH_FILTER="(uid={username})"
|
||||
|
||||
# Directory attributes copied onto the local user, and roles granted on first login.
|
||||
LDAP_ATTR_EMAIL=mail
|
||||
LDAP_ATTR_FULL_NAME=cn
|
||||
LDAP_DEFAULT_ROLES=["user"]
|
||||
|
||||
# --- Bootstrap superuser (used by scripts/seed.py) ---
|
||||
FIRST_SUPERUSER_EMAIL=admin@example.com
|
||||
FIRST_SUPERUSER_PASSWORD=changethis
|
||||
@@ -44,13 +44,6 @@ token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
|
||||
- **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`.
|
||||
- **Credential verification** goes through `services/auth.authenticate(db, username,
|
||||
password)` — both login routes call it, not `crud.user.authenticate` directly.
|
||||
When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`,
|
||||
sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local
|
||||
row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`,
|
||||
`LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in
|
||||
`settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`.
|
||||
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
|
||||
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
|
||||
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ RUN uv sync --no-dev
|
||||
|
||||
# Copy the application.
|
||||
COPY . .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
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"]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
- Parts
|
||||
- Type
|
||||
- Scope
|
||||
- Adapter Ring
|
||||
- ARD
|
||||
- Polarizer
|
||||
- Flip Cover
|
||||
|
||||
- Attachment Point +
|
||||
- Name
|
||||
- Attachment
|
||||
- One Of
|
||||
- ISO Thread
|
||||
- UNC Thread
|
||||
- Outside Diameter
|
||||
- static value (to be attached to)
|
||||
- Clamp
|
||||
- min
|
||||
- max
|
||||
- Manufacturers
|
||||
- Models
|
||||
- Colours
|
||||
- Part Types
|
||||
@@ -1,7 +1,11 @@
|
||||
VERSION := $(shell git describe --tags --always)
|
||||
IMAGE_NAME := fitguide
|
||||
|
||||
sync:
|
||||
uv sync --extra dev
|
||||
|
||||
seed:
|
||||
uv run alembic upgrade head
|
||||
uv run python -m scripts.seed
|
||||
|
||||
dev:
|
||||
@@ -17,3 +21,17 @@ 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 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
|
||||
@@ -4,19 +4,19 @@ A production-shaped starting point for a web app that serves both **HTML (HTMX)*
|
||||
|
||||
## Stack
|
||||
|
||||
| Concern | Choice |
|
||||
| -------------------- | ----------------------------------------------------------- |
|
||||
| Web framework | FastAPI |
|
||||
| HTML / interactivity | Jinja2 + HTMX |
|
||||
| ORM | SQLAlchemy 2.0 (async) |
|
||||
| Migrations | Alembic |
|
||||
| Database | SQLite (default) or Postgres — one `DATABASE_URL` |
|
||||
| Auth | JWT access + refresh tokens, rotation, DB-backed revocation |
|
||||
| Authorization | RBAC: users → roles → permissions |
|
||||
| Admin UI | Custom HTMX dashboard (user & role CRUD) |
|
||||
| Config | env vars **or** `.env` (pydantic-settings) |
|
||||
| Tooling | uv, pytest, ruff |
|
||||
| Packaging | Dockerfile + docker-compose |
|
||||
| Concern | Choice |
|
||||
|----------------|--------|
|
||||
| Web framework | FastAPI |
|
||||
| HTML / interactivity | Jinja2 + HTMX |
|
||||
| ORM | SQLAlchemy 2.0 (async) |
|
||||
| Migrations | Alembic |
|
||||
| Database | SQLite (default) or Postgres — one `DATABASE_URL` |
|
||||
| Auth | JWT access + refresh tokens, rotation, DB-backed revocation |
|
||||
| Authorization | RBAC: users → roles → permissions |
|
||||
| Admin UI | Custom HTMX dashboard (user & role CRUD) |
|
||||
| Config | env vars **or** `.env` (pydantic-settings) |
|
||||
| Tooling | uv, pytest, ruff |
|
||||
| Packaging | Dockerfile + docker-compose |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -154,7 +154,6 @@ Every setting is read from an environment variable first, then `.env`
|
||||
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
|
||||
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
|
||||
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
|
||||
| `LDAP_ENABLED` | `false` | validate logins against LDAP/AD (see below) |
|
||||
|
||||
## Auth model
|
||||
|
||||
@@ -169,28 +168,6 @@ stores the access token in an httpOnly cookie so pages work without client JS.
|
||||
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
|
||||
flag, so logout and rotation genuinely invalidate them.
|
||||
|
||||
### LDAP / Active Directory (optional)
|
||||
|
||||
Set `LDAP_ENABLED=true` to validate logins against a directory. On a successful
|
||||
bind, a local user is **provisioned on first login** (with an unusable local
|
||||
password and the roles in `LDAP_DEFAULT_ROLES`) so RBAC, tokens, and the admin
|
||||
UI work unchanged. Existing accounts still authenticate against the local
|
||||
database as a fallback, so the seeded superuser keeps working.
|
||||
|
||||
Two lookup modes (configured in `.env`, see `.env.example`):
|
||||
|
||||
- **Direct bind** — set `LDAP_BIND_DN_TEMPLATE`, e.g.
|
||||
`uid={username},ou=people,dc=example,dc=com`; the login name is substituted
|
||||
and used to bind directly.
|
||||
- **Search + bind** — leave the template empty and set `LDAP_BIND_DN` /
|
||||
`LDAP_BIND_PASSWORD` (a service account) plus `LDAP_USER_SEARCH_BASE` /
|
||||
`LDAP_USER_SEARCH_FILTER`; the user is located, then re-bound to verify the
|
||||
password.
|
||||
|
||||
The local user's email/name come from the `LDAP_ATTR_EMAIL` / `LDAP_ATTR_FULL_NAME`
|
||||
attributes. Use `ldaps://` (`LDAP_USE_SSL=true`) or `LDAP_START_TLS=true` in
|
||||
production so credentials aren't sent in cleartext.
|
||||
|
||||
## RBAC
|
||||
|
||||
`users → roles → permissions`. Permissions follow `resource:action`
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""add auth_provider to users
|
||||
|
||||
Revision ID: 0002_add_auth_provider
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-07-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_add_auth_provider"
|
||||
down_revision: str | None = "0001_initial"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"auth_provider",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="local",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "auth_provider")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add part_colours
|
||||
|
||||
Revision ID: 0c3d1a5cff1f
|
||||
Revises: 5b6a7817b408
|
||||
Create Date: 2026-07-02 12:22:24.106795
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0c3d1a5cff1f"
|
||||
down_revision: Union[str, None] = "5b6a7817b408"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add_fits_tables
|
||||
|
||||
Revision ID: 1b99be8a9b1e
|
||||
Revises: adf4a5b9493e
|
||||
Create Date: 2026-07-14 15:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1b99be8a9b1e"
|
||||
down_revision: Union[str, None] = "adf4a5b9493e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"fits",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("attachment_point_a_id", sa.Integer(), nullable=False),
|
||||
sa.Column("attachment_point_b_id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_a_id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_b_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"label",
|
||||
sa.Enum("possible", "exact", "none", name="fitlabel"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["attachment_point_b_id"], ["attachment_points.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_a_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_b_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"fit_notes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("fit_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("username", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("fit_notes")
|
||||
op.drop_table("fits")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add static_connections
|
||||
|
||||
Revision ID: 5b6a7817b408
|
||||
Revises: 7be5ef599d3f
|
||||
Create Date: 2026-07-02 11:57:13.095417
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5b6a7817b408"
|
||||
down_revision: Union[str, None] = "7be5ef599d3f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add iso_threads and unc_threads tables
|
||||
|
||||
Revision ID: 627d4460a94f
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-06-24 21:50:05.340235
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "627d4460a94f"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"iso_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("diameter_mm", sa.Float(), nullable=False),
|
||||
sa.Column("pitch_mm", sa.Float(), nullable=False),
|
||||
sa.Column("depth_mm", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"unc_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("diameter_fraction", sa.String(length=16), nullable=False),
|
||||
sa.Column("tpi", sa.Integer(), nullable=False),
|
||||
sa.Column("series", sa.String(length=8), nullable=False),
|
||||
sa.Column("depth_in", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("unc_threads")
|
||||
op.drop_table("iso_threads")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add attachment_point_names
|
||||
|
||||
Revision ID: 70e48bda8829
|
||||
Revises: c7882cedc9ae
|
||||
Create Date: 2026-07-02 13:54:58.335394
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "70e48bda8829"
|
||||
down_revision: Union[str, None] = "c7882cedc9ae"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_imported_parts_tables
|
||||
|
||||
Revision ID: 7be5ef599d3f
|
||||
Revises: b60d0aa3ceda
|
||||
Create Date: 2026-06-25 15:29:20.685050
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7be5ef599d3f"
|
||||
down_revision: Union[str, None] = "b60d0aa3ceda"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add augmented_parts
|
||||
|
||||
Revision ID: 8bc411b5814e
|
||||
Revises: 0c3d1a5cff1f
|
||||
Create Date: 2026-07-02 12:37:59.457033
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "8bc411b5814e"
|
||||
down_revision: Union[str, None] = "0c3d1a5cff1f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add_manufacturers_table
|
||||
|
||||
Revision ID: a192abbf4091
|
||||
Revises: 627d4460a94f
|
||||
Create Date: 2026-06-25 10:32:11.398529
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a192abbf4091"
|
||||
down_revision: Union[str, None] = "627d4460a94f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"manufacturers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=False),
|
||||
sa.Column("logo_url", sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"series",
|
||||
existing_type=sa.VARCHAR(length=8),
|
||||
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"series",
|
||||
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||
type_=sa.VARCHAR(length=8),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
op.drop_table("manufacturers")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add_scope_models_table
|
||||
|
||||
Revision ID: a52e7ca7a925
|
||||
Revises: a192abbf4091
|
||||
Create Date: 2026-06-25 10:35:40.227253
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a52e7ca7a925"
|
||||
down_revision: Union[str, None] = "a192abbf4091"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"scope_models",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("scope_models")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add attachment_points
|
||||
|
||||
Revision ID: adf4a5b9493e
|
||||
Revises: 70e48bda8829
|
||||
Create Date: 2026-07-02 14:03:23.561253
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "adf4a5b9493e"
|
||||
down_revision: Union[str, None] = "70e48bda8829"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_sub_part_types_table
|
||||
|
||||
Revision ID: b60d0aa3ceda
|
||||
Revises: fd5379986da4
|
||||
Create Date: 2026-06-25 11:29:20.420056
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b60d0aa3ceda"
|
||||
down_revision: Union[str, None] = "fd5379986da4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add augmented_part_imported_parts
|
||||
|
||||
Revision ID: bfbce324cd5f
|
||||
Revises: 8bc411b5814e
|
||||
Create Date: 2026-07-02 12:46:27.493942
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bfbce324cd5f"
|
||||
down_revision: Union[str, None] = "8bc411b5814e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add clamp_connections
|
||||
|
||||
Revision ID: c7882cedc9ae
|
||||
Revises: bfbce324cd5f
|
||||
Create Date: 2026-07-02 13:31:57.064381
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c7882cedc9ae"
|
||||
down_revision: Union[str, None] = "bfbce324cd5f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,54 @@
|
||||
"""add LDAP user fields and default role flag
|
||||
|
||||
Revision ID: c9f1a2b3d4e5
|
||||
Revises: 1b99be8a9b1e
|
||||
Create Date: 2026-07-16 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c9f1a2b3d4e5"
|
||||
down_revision: str | None = "1b99be8a9b1e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Batch mode so SQLite can alter the nullability of hashed_password.
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.add_column(sa.Column("username", sa.String(length=255), nullable=True))
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"auth_source",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="local",
|
||||
)
|
||||
)
|
||||
batch.alter_column(
|
||||
"hashed_password", existing_type=sa.String(length=255), nullable=True
|
||||
)
|
||||
batch.create_index("ix_users_username", ["username"], unique=True)
|
||||
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column(
|
||||
"is_default", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("roles", "is_default")
|
||||
|
||||
with op.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_username")
|
||||
batch.alter_column(
|
||||
"hashed_password", existing_type=sa.String(length=255), nullable=False
|
||||
)
|
||||
batch.drop_column("auth_source")
|
||||
batch.drop_column("username")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add_scope_infos_table
|
||||
|
||||
Revision ID: d1e2f3a4b5c6
|
||||
Revises: c9f1a2b3d4e5
|
||||
Create Date: 2026-07-16 20:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d1e2f3a4b5c6"
|
||||
down_revision: Union[str, None] = "c9f1a2b3d4e5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scope_infos",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
|
||||
sa.Column("magnification_min", sa.Float(), nullable=True),
|
||||
sa.Column("magnification_max", sa.Float(), nullable=True),
|
||||
sa.Column("model_suffix", sa.String(), nullable=True),
|
||||
sa.Column("objective_thread_pitch", sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("augmented_part_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("scope_infos")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add_part_types_table
|
||||
|
||||
Revision ID: e23aa8dd2536
|
||||
Revises: a52e7ca7a925
|
||||
Create Date: 2026-06-25 10:49:56.463466
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e23aa8dd2536"
|
||||
down_revision: Union[str, None] = "a52e7ca7a925"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"part_types",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("part_types")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_audit_logs_table
|
||||
|
||||
Revision ID: fd5379986da4
|
||||
Revises: e23aa8dd2536
|
||||
Create Date: 2026-06-25 11:06:13.028067
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fd5379986da4"
|
||||
down_revision: Union[str, None] = "e23aa8dd2536"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import attachment_point_name as apn_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.attachment_point_name import (
|
||||
AttachmentPointNameCreate,
|
||||
AttachmentPointNameRead,
|
||||
AttachmentPointNameUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AttachmentPointNameRead])
|
||||
async def list_attachment_point_names(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_point_names:read")),
|
||||
):
|
||||
return await apn_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=AttachmentPointNameRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_attachment_point_name(
|
||||
payload: AttachmentPointNameCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_point_names:write")),
|
||||
):
|
||||
apn = await apn_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="AttachmentPointName",
|
||||
resource_id=apn.id,
|
||||
description=audit_crud.describe_create("AttachmentPointName", apn.id, apn.name),
|
||||
)
|
||||
return apn
|
||||
|
||||
|
||||
@router.get("/{apn_id}", response_model=AttachmentPointNameRead)
|
||||
async def get_attachment_point_name(
|
||||
apn_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_point_names:read")),
|
||||
):
|
||||
apn = await apn_crud.get(db, apn_id)
|
||||
if apn is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||
return apn
|
||||
|
||||
|
||||
@router.patch("/{apn_id}", response_model=AttachmentPointNameRead)
|
||||
async def update_attachment_point_name(
|
||||
apn_id: int,
|
||||
payload: AttachmentPointNameUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_point_names:write")),
|
||||
):
|
||||
apn = await apn_crud.get(db, apn_id)
|
||||
if apn is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
apn = await apn_crud.update(db, apn, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AttachmentPointName",
|
||||
resource_id=apn.id,
|
||||
description=audit_crud.describe_update("AttachmentPointName", apn.id, changes),
|
||||
)
|
||||
return apn
|
||||
|
||||
|
||||
@router.delete("/{apn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_attachment_point_name(
|
||||
apn_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_point_names:delete")),
|
||||
):
|
||||
apn = await apn_crud.get(db, apn_id)
|
||||
if apn is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||
name = apn.name
|
||||
await apn_crud.delete(db, apn)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="AttachmentPointName",
|
||||
resource_id=apn_id,
|
||||
description=audit_crud.describe_delete("AttachmentPointName", apn_id, name),
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import attachment_point as attachment_point_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.attachment_point import (
|
||||
AttachmentPointCreate,
|
||||
AttachmentPointRead,
|
||||
AttachmentPointUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _connection_label(ap) -> str:
|
||||
if ap.iso_thread:
|
||||
return f"M{ap.iso_thread.diameter_mm}×{ap.iso_thread.pitch_mm}"
|
||||
if ap.unc_thread:
|
||||
return f"{ap.unc_thread.diameter_fraction}-{ap.unc_thread.tpi} {ap.unc_thread.series}"
|
||||
if ap.clamp_connection:
|
||||
cc = ap.clamp_connection
|
||||
if cc.min_mm is not None:
|
||||
return f"{cc.min_mm}–{cc.max_mm} mm"
|
||||
return f"{cc.min_in}–{cc.max_in} in"
|
||||
if ap.static_connection:
|
||||
return f"{ap.static_connection.od_mm} mm OD"
|
||||
return "—"
|
||||
|
||||
|
||||
@router.get("", response_model=list[AttachmentPointRead])
|
||||
async def list_attachment_points(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_points:read")),
|
||||
):
|
||||
return await attachment_point_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=AttachmentPointRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_attachment_point(
|
||||
payload: AttachmentPointCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:write")),
|
||||
):
|
||||
conn_ids = [
|
||||
payload.iso_thread_id,
|
||||
payload.unc_thread_id,
|
||||
payload.clamp_connection_id,
|
||||
payload.static_connection_id,
|
||||
]
|
||||
if sum(v is not None for v in conn_ids) != 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
|
||||
)
|
||||
ap = await attachment_point_crud.create(db, **payload.model_dump())
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_create(
|
||||
"AttachmentPoint", ap.id, _connection_label(ap)
|
||||
),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.get("/{attachment_point_id}", response_model=AttachmentPointRead)
|
||||
async def get_attachment_point(
|
||||
attachment_point_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("attachment_points:read")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
return ap
|
||||
|
||||
|
||||
@router.patch("/{attachment_point_id}", response_model=AttachmentPointRead)
|
||||
async def update_attachment_point(
|
||||
attachment_point_id: int,
|
||||
payload: AttachmentPointUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:write")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
ap = await attachment_point_crud.update(db, ap, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_update("AttachmentPoint", ap.id, changes),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.delete("/{attachment_point_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_attachment_point(
|
||||
attachment_point_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_points:delete")),
|
||||
):
|
||||
ap = await attachment_point_crud.get(db, attachment_point_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
label = _connection_label(ap)
|
||||
await attachment_point_crud.delete(db, ap)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="AttachmentPoint",
|
||||
resource_id=attachment_point_id,
|
||||
description=audit_crud.describe_delete(
|
||||
"AttachmentPoint", attachment_point_id, label
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.audit_log import AuditLogRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditLogRead])
|
||||
async def list_audit_log(
|
||||
limit: int = 200,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("audit_log:read")),
|
||||
):
|
||||
return await audit_crud.list_recent(db, limit=limit)
|
||||
@@ -0,0 +1,134 @@
|
||||
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.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.augmented_part import (
|
||||
AugmentedPartCreate,
|
||||
AugmentedPartRead,
|
||||
AugmentedPartSyncResult,
|
||||
AugmentedPartUpdate,
|
||||
)
|
||||
from app.services import augmented_part_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[AugmentedPartRead])
|
||||
async def list_augmented_parts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("augmented_parts:read")),
|
||||
):
|
||||
return await augmented_part_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_augmented_part(
|
||||
payload: AugmentedPartCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
ap = await augmented_part_crud.create(
|
||||
db,
|
||||
sku=payload.sku,
|
||||
description=payload.description,
|
||||
colour_id=payload.colour_id,
|
||||
manufacturer_id=payload.manufacturer_id,
|
||||
scope_model_id=payload.scope_model_id,
|
||||
part_type_id=payload.part_type_id,
|
||||
sub_part_type_id=payload.sub_part_type_id,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_create(
|
||||
"AugmentedPart", ap.id, ap.sku or ap.description
|
||||
),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.post("/sync", response_model=AugmentedPartSyncResult)
|
||||
async def sync_augmented_parts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
result = await augmented_part_sync.sync(db)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Sync: {result.created} created, {result.deleted} deleted, "
|
||||
f"{result.skipped} skipped (no type match)"
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{augmented_part_id}", response_model=AugmentedPartRead)
|
||||
async def get_augmented_part(
|
||||
augmented_part_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("augmented_parts:read")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
return ap
|
||||
|
||||
|
||||
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
|
||||
async def update_augmented_part(
|
||||
augmented_part_id: int,
|
||||
payload: AugmentedPartUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
ap = await augmented_part_crud.update(db, ap, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=ap.id,
|
||||
description=audit_crud.describe_update("AugmentedPart", ap.id, changes),
|
||||
)
|
||||
return ap
|
||||
|
||||
|
||||
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_augmented_part(
|
||||
augmented_part_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:delete")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
label = ap.sku or ap.description
|
||||
await augmented_part_crud.delete(db, ap)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=augmented_part_id,
|
||||
description=audit_crud.describe_delete(
|
||||
"AugmentedPart", augmented_part_id, label
|
||||
),
|
||||
)
|
||||
+35
-11
@@ -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,32 +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.auth import (
|
||||
RefreshError,
|
||||
authenticate,
|
||||
issue_token_pair,
|
||||
rotate_refresh_token,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -45,8 +48,8 @@ async def login(
|
||||
form: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
|
||||
user = await authenticate(db, form.username, form.password)
|
||||
# OAuth2 form uses ``username``; we treat it as the email.
|
||||
user = await user_crud.authenticate(db, form.username, form.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -57,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
|
||||
),
|
||||
)
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import attachment_point as attachment_point_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import fit as fit_crud
|
||||
from app.crud import fit_note as fit_note_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.fit import FitLabel
|
||||
from app.models.user import User
|
||||
from app.schemas.fit import (
|
||||
FitCreate,
|
||||
FitNoteCreate,
|
||||
FitNoteRead,
|
||||
FitRead,
|
||||
FitRecomputeResult,
|
||||
FitUpdate,
|
||||
)
|
||||
from app.services import fit_matching
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[FitRead])
|
||||
async def list_fits(
|
||||
augmented_part_id: int | None = None,
|
||||
label: FitLabel | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("fits:read")),
|
||||
):
|
||||
if augmented_part_id is not None:
|
||||
fits = await fit_crud.list_for_part(db, augmented_part_id)
|
||||
if label is not None:
|
||||
fits = [f for f in fits if f.label == label]
|
||||
return fits[skip : skip + limit]
|
||||
return await fit_crud.list_all(db, skip=skip, limit=limit, label=label)
|
||||
|
||||
|
||||
@router.get("/{fit_id}", response_model=FitRead)
|
||||
async def get_fit(
|
||||
fit_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("fits:read")),
|
||||
):
|
||||
fit = await fit_crud.get(db, fit_id)
|
||||
if fit is None:
|
||||
raise HTTPException(status_code=404, detail="Fit not found")
|
||||
return fit
|
||||
|
||||
|
||||
@router.post("", response_model=FitRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fit(
|
||||
payload: FitCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("fits:write")),
|
||||
):
|
||||
"""Manually declare a fit between two attachment points; recorded as Exact."""
|
||||
if payload.attachment_point_a_id == payload.attachment_point_b_id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="attachment points must be different"
|
||||
)
|
||||
ap_a = await attachment_point_crud.get(db, payload.attachment_point_a_id)
|
||||
ap_b = await attachment_point_crud.get(db, payload.attachment_point_b_id)
|
||||
if ap_a is None or ap_b is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point not found")
|
||||
if ap_a.augmented_part_id == ap_b.augmented_part_id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="a part cannot fit itself"
|
||||
)
|
||||
existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Fit already exists")
|
||||
|
||||
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="Fit",
|
||||
resource_id=fit.id,
|
||||
description=audit_crud.describe_create(
|
||||
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
|
||||
),
|
||||
)
|
||||
return fit
|
||||
|
||||
|
||||
@router.patch("/{fit_id}", response_model=FitRead)
|
||||
async def update_fit_label(
|
||||
fit_id: int,
|
||||
payload: FitUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("fits:write")),
|
||||
):
|
||||
"""Promote a Possible fit to Exact, or mark a pairing as None (impossible)."""
|
||||
fit = await fit_crud.get(db, fit_id)
|
||||
if fit is None:
|
||||
raise HTTPException(status_code=404, detail="Fit not found")
|
||||
fit = await fit_crud.set_label(db, fit, payload.label)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="Fit",
|
||||
resource_id=fit.id,
|
||||
description=audit_crud.describe_update(
|
||||
"Fit", fit.id, {"label": payload.label.value}
|
||||
),
|
||||
)
|
||||
return fit
|
||||
|
||||
|
||||
@router.delete("/{fit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_fit(
|
||||
fit_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("fits:delete")),
|
||||
):
|
||||
fit = await fit_crud.get(db, fit_id)
|
||||
if fit is None:
|
||||
raise HTTPException(status_code=404, detail="Fit not found")
|
||||
label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}"
|
||||
await fit_crud.delete(db, fit)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="Fit",
|
||||
resource_id=fit_id,
|
||||
description=audit_crud.describe_delete("Fit", fit_id, label),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/recompute", response_model=FitRecomputeResult)
|
||||
async def recompute_fits(
|
||||
augmented_part_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("fits:write")),
|
||||
):
|
||||
"""Re-run the matching algorithm, scoped to one part or across all parts."""
|
||||
if augmented_part_id is not None:
|
||||
result = await fit_matching.recompute_for_part(db, augmented_part_id)
|
||||
else:
|
||||
result = await fit_matching.recompute_all(db)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="Fit",
|
||||
resource_id=augmented_part_id,
|
||||
description=f"Recomputed fits: {result.created} created, {result.removed} removed",
|
||||
)
|
||||
return FitRecomputeResult(created=result.created, removed=result.removed)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{fit_id}/notes", response_model=FitNoteRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_fit_note(
|
||||
fit_id: int,
|
||||
payload: FitNoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("fits:write")),
|
||||
):
|
||||
fit = await fit_crud.get(db, fit_id)
|
||||
if fit is None:
|
||||
raise HTTPException(status_code=404, detail="Fit not found")
|
||||
return await fit_note_crud.create(db, fit=fit, author=actor, body=payload.body)
|
||||
@@ -0,0 +1,139 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import imported_part as imported_part_crud
|
||||
from app.crud import imported_part_config as config_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.imported_part import (
|
||||
ImportedPartConfigRead,
|
||||
ImportedPartConfigUpdate,
|
||||
ImportedPartRead,
|
||||
SyncResult,
|
||||
)
|
||||
from app.services import odata as odata_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ImportedPartRead])
|
||||
async def list_imported_parts(
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:read")),
|
||||
):
|
||||
return await imported_part_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/config", response_model=ImportedPartConfigRead)
|
||||
async def get_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
config = await config_crud.get(db)
|
||||
if config is None:
|
||||
return ImportedPartConfigRead(odata_url=None, username=None, has_password=False)
|
||||
return ImportedPartConfigRead(
|
||||
odata_url=config.odata_url,
|
||||
username=config.username,
|
||||
has_password=bool(config.password),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=ImportedPartConfigRead)
|
||||
async def update_config(
|
||||
payload: ImportedPartConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
current = await config_crud.get(db)
|
||||
# Keep existing password if not provided in the update
|
||||
new_password = (
|
||||
payload.password
|
||||
if payload.password is not None
|
||||
else (current.password if current else None)
|
||||
)
|
||||
config = await config_crud.upsert(
|
||||
db,
|
||||
odata_url=payload.odata_url,
|
||||
username=payload.username,
|
||||
password=new_password,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="ImportedPartConfig",
|
||||
resource_id=1,
|
||||
description="Updated ImportedPart OData configuration",
|
||||
)
|
||||
return ImportedPartConfigRead(
|
||||
odata_url=config.odata_url,
|
||||
username=config.username,
|
||||
has_password=bool(config.password),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=SyncResult)
|
||||
async def refresh_imported_parts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:write")),
|
||||
):
|
||||
config = await config_crud.get(db)
|
||||
if not config or not config.odata_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="OData URL is not configured.",
|
||||
)
|
||||
records = await odata_service.fetch(
|
||||
config.odata_url, config.username, config.password
|
||||
)
|
||||
created, updated = await imported_part_crud.upsert_bulk(db, records)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="ImportedPart",
|
||||
resource_id=None,
|
||||
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
|
||||
)
|
||||
return SyncResult(created=created, updated=updated, total=len(records))
|
||||
|
||||
|
||||
@router.get("/{inventory_id}", response_model=ImportedPartRead)
|
||||
async def get_imported_part(
|
||||
inventory_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("imported_parts:read")),
|
||||
):
|
||||
part = await imported_part_crud.get(db, inventory_id)
|
||||
if part is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
|
||||
)
|
||||
return part
|
||||
|
||||
|
||||
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_imported_part(
|
||||
inventory_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("imported_parts:delete")),
|
||||
):
|
||||
part = await imported_part_crud.get(db, inventory_id)
|
||||
if part is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
|
||||
)
|
||||
await imported_part_crud.delete(db, part)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="ImportedPart",
|
||||
resource_id=None,
|
||||
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import iso_thread as iso_thread_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.iso_thread import IsoThreadCreate, IsoThreadRead, IsoThreadUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[IsoThreadRead])
|
||||
async def list_iso_threads(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("iso_threads:read")),
|
||||
):
|
||||
return await iso_thread_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=IsoThreadRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_iso_thread(
|
||||
payload: IsoThreadCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("iso_threads:write")),
|
||||
):
|
||||
thread = await iso_thread_crud.create(
|
||||
db,
|
||||
diameter_mm=payload.diameter_mm,
|
||||
pitch_mm=payload.pitch_mm,
|
||||
depth_mm=payload.depth_mm,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="IsoThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_create(
|
||||
"IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"
|
||||
),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
|
||||
async def get_iso_thread(
|
||||
iso_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("iso_threads:read")),
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.patch("/{iso_thread_id}", response_model=IsoThreadRead)
|
||||
async def update_iso_thread(
|
||||
iso_thread_id: int,
|
||||
payload: IsoThreadUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("iso_threads:write")),
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
thread = await iso_thread_crud.update(db, thread, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="IsoThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_update("IsoThread", thread.id, changes),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_iso_thread(
|
||||
iso_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("iso_threads:delete")),
|
||||
):
|
||||
thread = await iso_thread_crud.get(db, iso_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
|
||||
)
|
||||
name = f"M{thread.diameter_mm}×{thread.pitch_mm}"
|
||||
await iso_thread_crud.delete(db, thread)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="IsoThread",
|
||||
resource_id=iso_thread_id,
|
||||
description=audit_crud.describe_delete("IsoThread", iso_thread_id, name),
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import manufacturer as manufacturer_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.manufacturer import (
|
||||
ManufacturerCreate,
|
||||
ManufacturerRead,
|
||||
ManufacturerUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ManufacturerRead])
|
||||
async def list_manufacturers(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("manufacturers:read")),
|
||||
):
|
||||
return await manufacturer_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=ManufacturerRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_manufacturer(
|
||||
payload: ManufacturerCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manufacturers:write")),
|
||||
):
|
||||
m = await manufacturer_crud.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
url=str(payload.url),
|
||||
logo_url=str(payload.logo_url) if payload.logo_url else None,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="Manufacturer",
|
||||
resource_id=m.id,
|
||||
description=audit_crud.describe_create("Manufacturer", m.id, m.name),
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
@router.get("/{manufacturer_id}", response_model=ManufacturerRead)
|
||||
async def get_manufacturer(
|
||||
manufacturer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("manufacturers:read")),
|
||||
):
|
||||
m = await manufacturer_crud.get(db, manufacturer_id)
|
||||
if m is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
@router.patch("/{manufacturer_id}", response_model=ManufacturerRead)
|
||||
async def update_manufacturer(
|
||||
manufacturer_id: int,
|
||||
payload: ManufacturerUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manufacturers:write")),
|
||||
):
|
||||
m = await manufacturer_crud.get(db, manufacturer_id)
|
||||
if m is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
|
||||
)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "url" in data and data["url"] is not None:
|
||||
data["url"] = str(data["url"])
|
||||
if "logo_url" in data and data["logo_url"] is not None:
|
||||
data["logo_url"] = str(data["logo_url"])
|
||||
m = await manufacturer_crud.update(db, m, data)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="Manufacturer",
|
||||
resource_id=m.id,
|
||||
description=audit_crud.describe_update("Manufacturer", m.id, data),
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
@router.delete("/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_manufacturer(
|
||||
manufacturer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manufacturers:delete")),
|
||||
):
|
||||
m = await manufacturer_crud.get(db, manufacturer_id)
|
||||
if m is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
|
||||
)
|
||||
name = m.name
|
||||
await manufacturer_crud.delete(db, m)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="Manufacturer",
|
||||
resource_id=manufacturer_id,
|
||||
description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name),
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import part_colour as part_colour_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.part_colour import PartColourCreate, PartColourRead, PartColourUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[PartColourRead])
|
||||
async def list_part_colours(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("part_colours:read")),
|
||||
):
|
||||
return await part_colour_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=PartColourRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_part_colour(
|
||||
payload: PartColourCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_colours:write")),
|
||||
):
|
||||
pc = await part_colour_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="PartColour",
|
||||
resource_id=pc.id,
|
||||
description=audit_crud.describe_create("PartColour", pc.id, pc.name),
|
||||
)
|
||||
return pc
|
||||
|
||||
|
||||
@router.get("/{part_colour_id}", response_model=PartColourRead)
|
||||
async def get_part_colour(
|
||||
part_colour_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("part_colours:read")),
|
||||
):
|
||||
pc = await part_colour_crud.get(db, part_colour_id)
|
||||
if pc is None:
|
||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||
return pc
|
||||
|
||||
|
||||
@router.patch("/{part_colour_id}", response_model=PartColourRead)
|
||||
async def update_part_colour(
|
||||
part_colour_id: int,
|
||||
payload: PartColourUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_colours:write")),
|
||||
):
|
||||
pc = await part_colour_crud.get(db, part_colour_id)
|
||||
if pc is None:
|
||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
pc = await part_colour_crud.update(db, pc, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="PartColour",
|
||||
resource_id=pc.id,
|
||||
description=audit_crud.describe_update("PartColour", pc.id, changes),
|
||||
)
|
||||
return pc
|
||||
|
||||
|
||||
@router.delete("/{part_colour_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_part_colour(
|
||||
part_colour_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_colours:delete")),
|
||||
):
|
||||
pc = await part_colour_crud.get(db, part_colour_id)
|
||||
if pc is None:
|
||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||
name = pc.name
|
||||
await part_colour_crud.delete(db, pc)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="PartColour",
|
||||
resource_id=part_colour_id,
|
||||
description=audit_crud.describe_delete("PartColour", part_colour_id, name),
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import part_type as part_type_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.part_type import PartTypeCreate, PartTypeRead, PartTypeUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[PartTypeRead])
|
||||
async def list_part_types(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("part_types:read")),
|
||||
):
|
||||
return await part_type_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=PartTypeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_part_type(
|
||||
payload: PartTypeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_types:write")),
|
||||
):
|
||||
pt = await part_type_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="PartType",
|
||||
resource_id=pt.id,
|
||||
description=audit_crud.describe_create("PartType", pt.id, pt.name),
|
||||
)
|
||||
return pt
|
||||
|
||||
|
||||
@router.get("/{part_type_id}", response_model=PartTypeRead)
|
||||
async def get_part_type(
|
||||
part_type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("part_types:read")),
|
||||
):
|
||||
pt = await part_type_crud.get(db, part_type_id)
|
||||
if pt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
||||
)
|
||||
return pt
|
||||
|
||||
|
||||
@router.patch("/{part_type_id}", response_model=PartTypeRead)
|
||||
async def update_part_type(
|
||||
part_type_id: int,
|
||||
payload: PartTypeUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_types:write")),
|
||||
):
|
||||
pt = await part_type_crud.get(db, part_type_id)
|
||||
if pt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
pt = await part_type_crud.update(db, pt, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="PartType",
|
||||
resource_id=pt.id,
|
||||
description=audit_crud.describe_update("PartType", pt.id, changes),
|
||||
)
|
||||
return pt
|
||||
|
||||
|
||||
@router.delete("/{part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_part_type(
|
||||
part_type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_types:delete")),
|
||||
):
|
||||
pt = await part_type_crud.get(db, part_type_id)
|
||||
if pt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
||||
)
|
||||
name = pt.name
|
||||
await part_type_crud.delete(db, pt)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="PartType",
|
||||
resource_id=part_type_id,
|
||||
description=audit_crud.describe_delete("PartType", part_type_id, name),
|
||||
)
|
||||
+34
-5
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import role as role_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
@@ -22,18 +23,27 @@ async def list_roles(
|
||||
async def create_role(
|
||||
payload: RoleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
if await role_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
|
||||
)
|
||||
return await role_crud.create(
|
||||
role = await role_crud.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
permission_ids=payload.permission_ids,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="Role",
|
||||
resource_id=role.id,
|
||||
description=audit_crud.describe_create("Role", role.id, role.name),
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=list[PermissionRead])
|
||||
@@ -63,25 +73,44 @@ async def update_role(
|
||||
role_id: int,
|
||||
payload: RoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
role = await role_crud.get(db, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
role = await role_crud.update(db, role, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="Role",
|
||||
resource_id=role.id,
|
||||
description=audit_crud.describe_update("Role", role.id, changes),
|
||||
)
|
||||
return role
|
||||
|
||||
|
||||
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("roles:write")),
|
||||
actor: User = Depends(require_permission("roles:write")),
|
||||
):
|
||||
role = await role_crud.get(db, role_id)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
name = role.name
|
||||
await role_crud.delete(db, role)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="Role",
|
||||
resource_id=role_id,
|
||||
description=audit_crud.describe_delete("Role", role_id, name),
|
||||
)
|
||||
+65
-1
@@ -1,8 +1,72 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import auth, roles, users
|
||||
from app.api import (
|
||||
attachment_point_names,
|
||||
attachment_points,
|
||||
audit_log,
|
||||
augmented_parts,
|
||||
auth,
|
||||
clamp_connections,
|
||||
fits,
|
||||
imported_parts,
|
||||
iso_threads,
|
||||
manufacturers,
|
||||
part_colours,
|
||||
part_types,
|
||||
roles,
|
||||
scope_import,
|
||||
scope_models,
|
||||
static_connections,
|
||||
sub_part_types,
|
||||
unc_threads,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.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(
|
||||
manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]
|
||||
)
|
||||
api_router.include_router(
|
||||
scope_models.router, prefix="/scope-models", tags=["scope-models"]
|
||||
)
|
||||
api_router.include_router(
|
||||
part_colours.router, prefix="/part-colours", tags=["part-colours"]
|
||||
)
|
||||
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
|
||||
api_router.include_router(
|
||||
sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]
|
||||
)
|
||||
api_router.include_router(
|
||||
static_connections.router, prefix="/static-connections", tags=["static-connections"]
|
||||
)
|
||||
api_router.include_router(
|
||||
clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"]
|
||||
)
|
||||
api_router.include_router(
|
||||
attachment_point_names.router,
|
||||
prefix="/attachment-point-names",
|
||||
tags=["attachment-point-names"],
|
||||
)
|
||||
api_router.include_router(
|
||||
attachment_points.router, prefix="/attachment-points", tags=["attachment-points"]
|
||||
)
|
||||
api_router.include_router(
|
||||
imported_parts.router, prefix="/imported-parts", tags=["imported-parts"]
|
||||
)
|
||||
api_router.include_router(
|
||||
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
|
||||
)
|
||||
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
|
||||
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
|
||||
api_router.include_router(
|
||||
scope_import.router, prefix="/scope-import", tags=["scope-import"]
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.scope_import import ScopeImportResult
|
||||
from app.services import scope_import as scope_import_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_XLSX_TYPES = {
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/octet-stream",
|
||||
"",
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=ScopeImportResult)
|
||||
async def import_scopes(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
filename = file.filename or ""
|
||||
if not filename.lower().endswith(".xlsx"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Expected an .xlsx spreadsheet.",
|
||||
)
|
||||
data = await file.read()
|
||||
try:
|
||||
result = await scope_import_service.import_from_bytes(db, data)
|
||||
except Exception as exc: # openpyxl / malformed workbook
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Could not read spreadsheet: {exc}",
|
||||
) from exc
|
||||
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Scope import ({filename}): {result.created} created, "
|
||||
f"{result.updated} updated, {result.skipped} skipped, "
|
||||
f"{result.errors} errors"
|
||||
),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import scope_model as scope_model_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.scope_model import ScopeModelCreate, ScopeModelRead, ScopeModelUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScopeModelRead])
|
||||
async def list_scope_models(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("scope_models:read")),
|
||||
):
|
||||
return await scope_model_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=ScopeModelRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_scope_model(
|
||||
payload: ScopeModelCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("scope_models:write")),
|
||||
):
|
||||
sm = await scope_model_crud.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
manufacturer_id=payload.manufacturer_id,
|
||||
url=str(payload.url) if payload.url else None,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="ScopeModel",
|
||||
resource_id=sm.id,
|
||||
description=audit_crud.describe_create("ScopeModel", sm.id, sm.name),
|
||||
)
|
||||
return sm
|
||||
|
||||
|
||||
@router.get("/{scope_model_id}", response_model=ScopeModelRead)
|
||||
async def get_scope_model(
|
||||
scope_model_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("scope_models:read")),
|
||||
):
|
||||
sm = await scope_model_crud.get(db, scope_model_id)
|
||||
if sm is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
|
||||
)
|
||||
return sm
|
||||
|
||||
|
||||
@router.patch("/{scope_model_id}", response_model=ScopeModelRead)
|
||||
async def update_scope_model(
|
||||
scope_model_id: int,
|
||||
payload: ScopeModelUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("scope_models:write")),
|
||||
):
|
||||
sm = await scope_model_crud.get(db, scope_model_id)
|
||||
if sm is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
|
||||
)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "url" in data and data["url"] is not None:
|
||||
data["url"] = str(data["url"])
|
||||
sm = await scope_model_crud.update(db, sm, data)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="ScopeModel",
|
||||
resource_id=sm.id,
|
||||
description=audit_crud.describe_update("ScopeModel", sm.id, data),
|
||||
)
|
||||
return sm
|
||||
|
||||
|
||||
@router.delete("/{scope_model_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_scope_model(
|
||||
scope_model_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("scope_models:delete")),
|
||||
):
|
||||
sm = await scope_model_crud.get(db, scope_model_id)
|
||||
if sm is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
|
||||
)
|
||||
name = sm.name
|
||||
await scope_model_crud.delete(db, sm)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="ScopeModel",
|
||||
resource_id=scope_model_id,
|
||||
description=audit_crud.describe_delete("ScopeModel", scope_model_id, name),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import static_connection as static_connection_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.static_connection import (
|
||||
StaticConnectionCreate,
|
||||
StaticConnectionRead,
|
||||
StaticConnectionUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[StaticConnectionRead])
|
||||
async def list_static_connections(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("static_connections:read")),
|
||||
):
|
||||
return await static_connection_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=StaticConnectionRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_static_connection(
|
||||
payload: StaticConnectionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("static_connections:write")),
|
||||
):
|
||||
sc = await static_connection_crud.create(
|
||||
db, od_mm=payload.od_mm, od_in=payload.od_in
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="StaticConnection",
|
||||
resource_id=sc.id,
|
||||
description=audit_crud.describe_create(
|
||||
"StaticConnection", sc.id, f"{sc.od_mm} mm"
|
||||
),
|
||||
)
|
||||
return sc
|
||||
|
||||
|
||||
@router.get("/{static_connection_id}", response_model=StaticConnectionRead)
|
||||
async def get_static_connection(
|
||||
static_connection_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("static_connections:read")),
|
||||
):
|
||||
sc = await static_connection_crud.get(db, static_connection_id)
|
||||
if sc is None:
|
||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||
return sc
|
||||
|
||||
|
||||
@router.patch("/{static_connection_id}", response_model=StaticConnectionRead)
|
||||
async def update_static_connection(
|
||||
static_connection_id: int,
|
||||
payload: StaticConnectionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("static_connections:write")),
|
||||
):
|
||||
sc = await static_connection_crud.get(db, static_connection_id)
|
||||
if sc is None:
|
||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
sc = await static_connection_crud.update(db, sc, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="StaticConnection",
|
||||
resource_id=sc.id,
|
||||
description=audit_crud.describe_update("StaticConnection", sc.id, changes),
|
||||
)
|
||||
return sc
|
||||
|
||||
|
||||
@router.delete("/{static_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_static_connection(
|
||||
static_connection_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("static_connections:delete")),
|
||||
):
|
||||
sc = await static_connection_crud.get(db, static_connection_id)
|
||||
if sc is None:
|
||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||
od_mm = sc.od_mm
|
||||
await static_connection_crud.delete(db, sc)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="StaticConnection",
|
||||
resource_id=static_connection_id,
|
||||
description=audit_crud.describe_delete(
|
||||
"StaticConnection", static_connection_id, f"{od_mm} mm"
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import sub_part_type as sub_part_type_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.sub_part_type import (
|
||||
SubPartTypeCreate,
|
||||
SubPartTypeRead,
|
||||
SubPartTypeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubPartTypeRead])
|
||||
async def list_sub_part_types(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("sub_part_types:read")),
|
||||
):
|
||||
return await sub_part_type_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=SubPartTypeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_sub_part_type(
|
||||
payload: SubPartTypeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("sub_part_types:write")),
|
||||
):
|
||||
spt = await sub_part_type_crud.create(
|
||||
db, name=payload.name, part_type_id=payload.part_type_id
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="SubPartType",
|
||||
resource_id=spt.id,
|
||||
description=audit_crud.describe_create("SubPartType", spt.id, spt.name),
|
||||
)
|
||||
return spt
|
||||
|
||||
|
||||
@router.get("/{sub_part_type_id}", response_model=SubPartTypeRead)
|
||||
async def get_sub_part_type(
|
||||
sub_part_type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("sub_part_types:read")),
|
||||
):
|
||||
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||
if spt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||
)
|
||||
return spt
|
||||
|
||||
|
||||
@router.patch("/{sub_part_type_id}", response_model=SubPartTypeRead)
|
||||
async def update_sub_part_type(
|
||||
sub_part_type_id: int,
|
||||
payload: SubPartTypeUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("sub_part_types:write")),
|
||||
):
|
||||
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||
if spt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
spt = await sub_part_type_crud.update(db, spt, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="SubPartType",
|
||||
resource_id=spt.id,
|
||||
description=audit_crud.describe_update("SubPartType", spt.id, changes),
|
||||
)
|
||||
return spt
|
||||
|
||||
|
||||
@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_sub_part_type(
|
||||
sub_part_type_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("sub_part_types:delete")),
|
||||
):
|
||||
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||
if spt is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||
)
|
||||
name = spt.name
|
||||
await sub_part_type_crud.delete(db, spt)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="SubPartType",
|
||||
resource_id=sub_part_type_id,
|
||||
description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name),
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import unc_thread as unc_thread_crud
|
||||
from app.deps import require_permission
|
||||
from app.models.user import User
|
||||
from app.schemas.unc_thread import UncThreadCreate, UncThreadRead, UncThreadUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UncThreadRead])
|
||||
async def list_unc_threads(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("unc_threads:read")),
|
||||
):
|
||||
return await unc_thread_crud.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.post("", response_model=UncThreadRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_unc_thread(
|
||||
payload: UncThreadCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("unc_threads:write")),
|
||||
):
|
||||
thread = await unc_thread_crud.create(
|
||||
db,
|
||||
diameter_fraction=payload.diameter_fraction,
|
||||
tpi=payload.tpi,
|
||||
series=payload.series,
|
||||
depth_in=payload.depth_in,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="UncThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_create(
|
||||
"UncThread",
|
||||
thread.id,
|
||||
f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}",
|
||||
),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.get("/{unc_thread_id}", response_model=UncThreadRead)
|
||||
async def get_unc_thread(
|
||||
unc_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("unc_threads:read")),
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.patch("/{unc_thread_id}", response_model=UncThreadRead)
|
||||
async def update_unc_thread(
|
||||
unc_thread_id: int,
|
||||
payload: UncThreadUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("unc_threads:write")),
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
thread = await unc_thread_crud.update(db, thread, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="UncThread",
|
||||
resource_id=thread.id,
|
||||
description=audit_crud.describe_update("UncThread", thread.id, changes),
|
||||
)
|
||||
return thread
|
||||
|
||||
|
||||
@router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_unc_thread(
|
||||
unc_thread_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("unc_threads:delete")),
|
||||
):
|
||||
thread = await unc_thread_crud.get(db, unc_thread_id)
|
||||
if thread is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
|
||||
)
|
||||
name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"
|
||||
await unc_thread_crud.delete(db, thread)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="UncThread",
|
||||
resource_id=unc_thread_id,
|
||||
description=audit_crud.describe_delete("UncThread", unc_thread_id, name),
|
||||
)
|
||||
+42
-13
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.models.user import User
|
||||
@@ -29,13 +30,13 @@ async def list_users(
|
||||
async def create_user(
|
||||
payload: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:write")),
|
||||
actor: User = Depends(require_permission("users:write")),
|
||||
):
|
||||
if await user_crud.get_by_email(db, payload.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
return await user_crud.create(
|
||||
created = await user_crud.create(
|
||||
db,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
@@ -43,6 +44,15 @@ async def create_user(
|
||||
is_superuser=payload.is_superuser,
|
||||
role_ids=payload.role_ids,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="create",
|
||||
resource_type="User",
|
||||
resource_id=created.id,
|
||||
description=audit_crud.describe_create("User", created.id, created.email),
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
@@ -51,12 +61,12 @@ async def get_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:read")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
return user
|
||||
return target
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead)
|
||||
@@ -64,25 +74,44 @@ async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:write")),
|
||||
actor: User = Depends(require_permission("users:write")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
target = await user_crud.update(db, target, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="update",
|
||||
resource_type="User",
|
||||
resource_id=user_id,
|
||||
description=audit_crud.describe_update("User", user_id, changes),
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_permission("users:delete")),
|
||||
actor: User = Depends(require_permission("users:delete")),
|
||||
):
|
||||
user = await user_crud.get(db, user_id)
|
||||
if user is None:
|
||||
target = await user_crud.get(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
await user_crud.delete(db, user)
|
||||
email = target.email
|
||||
await user_crud.delete(db, target)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="User",
|
||||
resource_id=user_id,
|
||||
description=audit_crud.describe_delete("User", user_id, email),
|
||||
)
|
||||
+13
-34
@@ -37,43 +37,22 @@ class Settings(BaseSettings):
|
||||
cookie_name: str = "access_token"
|
||||
cookie_secure: bool = False
|
||||
|
||||
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
|
||||
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
|
||||
# ``{username}`` substituted) and bound directly with their password.
|
||||
ldap_enabled: bool = False
|
||||
ldap_host: str = "localhost"
|
||||
ldap_port: int = 389
|
||||
ldap_use_ssl: bool = False
|
||||
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
|
||||
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
|
||||
ldap_email_domain: str = "example.com"
|
||||
# LDAP attribute read for the provisioned user's display name.
|
||||
ldap_full_name_attr: str = "cn"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# --- LDAP / Active Directory (optional) --------------------------------
|
||||
# When ``ldap_enabled`` is true, login credentials are validated by binding
|
||||
# to the directory. A local ``User`` row is provisioned on first successful
|
||||
# login so RBAC, tokens, and the admin UI keep working. Local database auth
|
||||
# remains available as a fallback (so the seeded superuser can still log in).
|
||||
ldap_enabled: bool = False
|
||||
# e.g. "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"
|
||||
ldap_server_uri: str = ""
|
||||
ldap_use_ssl: bool = False
|
||||
ldap_start_tls: bool = False
|
||||
# Verify the server's TLS certificate (ldaps/StartTLS). Disable only for dev.
|
||||
ldap_tls_validate: bool = True
|
||||
ldap_ca_certs_file: str = ""
|
||||
|
||||
# Two ways to locate/authenticate the user (direct bind takes precedence):
|
||||
#
|
||||
# 1. Direct bind — build the user's DN from the login name, then bind as it:
|
||||
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
|
||||
ldap_bind_dn_template: str = ""
|
||||
#
|
||||
# 2. Search + bind — bind with a service account (or anonymously), search for
|
||||
# the user, then re-bind as the found DN to verify the password:
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
ldap_user_search_base: str = ""
|
||||
ldap_user_search_filter: str = "(uid={username})"
|
||||
|
||||
# Attributes read from the directory entry to populate the local user.
|
||||
ldap_attr_email: str = "mail"
|
||||
ldap_attr_full_name: str = "cn"
|
||||
|
||||
# Roles (by name) granted to a user provisioned from LDAP on first login.
|
||||
ldap_default_roles: list[str] = ["user"]
|
||||
|
||||
# Bootstrap superuser (used by scripts/seed.py)
|
||||
first_superuser_email: str = "admin@example.com"
|
||||
first_superuser_password: str = "changethis"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Password hashing and JWT token helpers."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -28,16 +27,6 @@ def verify_password(password: str, hashed: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def make_unusable_password() -> str:
|
||||
"""A sentinel hash that no password can match.
|
||||
|
||||
Used for externally-authenticated users (e.g. LDAP) who have no local
|
||||
password. It is not a valid bcrypt hash, so ``verify_password`` always
|
||||
returns ``False`` for it — local password auth can never succeed.
|
||||
"""
|
||||
return "!" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# --- JWT ---------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.attachment_point import AttachmentPoint
|
||||
|
||||
_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_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,203 @@
|
||||
from sqlalchemy import distinct, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.associations import augmented_part_imported_parts
|
||||
from app.models.augmented_part import AugmentedPart
|
||||
from app.models.imported_part import ImportedPart
|
||||
|
||||
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
|
||||
|
||||
|
||||
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
|
||||
return await db.get(AugmentedPart, augmented_part_id)
|
||||
|
||||
|
||||
async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
|
||||
result = await db.execute(
|
||||
select(AugmentedPart).where(AugmentedPart.sku == sku).order_by(AugmentedPart.id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[AugmentedPart]:
|
||||
result = await db.execute(
|
||||
select(AugmentedPart)
|
||||
.order_by(AugmentedPart.description)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def 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,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[AugmentedPart]:
|
||||
q = select(AugmentedPart)
|
||||
if description.strip():
|
||||
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
|
||||
if colour_id is not None:
|
||||
q = q.where(AugmentedPart.colour_id == colour_id)
|
||||
if manufacturer_id is not None:
|
||||
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
|
||||
if scope_model_id is not None:
|
||||
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
|
||||
if part_type_id is not None:
|
||||
q = q.where(AugmentedPart.part_type_id == part_type_id)
|
||||
if sub_part_type_id is not None:
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
result = await db.execute(
|
||||
q.order_by(AugmentedPart.description).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
colour_id: int | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
scope_model_id: int | None = None,
|
||||
part_type_id: int | None = None,
|
||||
sub_part_type_id: int | None = None,
|
||||
) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
q = select(func.count()).select_from(AugmentedPart)
|
||||
if description.strip():
|
||||
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
|
||||
if colour_id is not None:
|
||||
q = q.where(AugmentedPart.colour_id == colour_id)
|
||||
if manufacturer_id is not None:
|
||||
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
|
||||
if scope_model_id is not None:
|
||||
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
|
||||
if part_type_id is not None:
|
||||
q = q.where(AugmentedPart.part_type_id == part_type_id)
|
||||
if sub_part_type_id is not None:
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_unlinked_imported_parts_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
colour: str = "",
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[ImportedPart]:
|
||||
q = select(ImportedPart).where(
|
||||
ImportedPart.InventoryID.not_in(
|
||||
select(augmented_part_imported_parts.c.imported_part_id)
|
||||
)
|
||||
)
|
||||
if description.strip():
|
||||
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||
if colour.strip():
|
||||
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
|
||||
result = await db.execute(
|
||||
q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_unlinked_filtered(
|
||||
db: AsyncSession,
|
||||
description: str = "",
|
||||
colour: str = "",
|
||||
) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
q = (
|
||||
select(func.count())
|
||||
.select_from(ImportedPart)
|
||||
.where(
|
||||
ImportedPart.InventoryID.not_in(
|
||||
select(augmented_part_imported_parts.c.imported_part_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
if description.strip():
|
||||
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||
if colour.strip():
|
||||
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def distinct_unlinked_colours(db: AsyncSession) -> list[str]:
|
||||
q = (
|
||||
select(distinct(ImportedPart.TBXColour))
|
||||
.where(
|
||||
ImportedPart.TBXColour.isnot(None),
|
||||
ImportedPart.InventoryID.not_in(
|
||||
select(augmented_part_imported_parts.c.imported_part_id)
|
||||
),
|
||||
)
|
||||
.order_by(ImportedPart.TBXColour)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
sku: str | None,
|
||||
description: str,
|
||||
colour_id: int,
|
||||
manufacturer_id: int,
|
||||
scope_model_id: int | None,
|
||||
part_type_id: int,
|
||||
sub_part_type_id: int | None,
|
||||
) -> AugmentedPart:
|
||||
ap = AugmentedPart(
|
||||
sku=sku,
|
||||
description=description,
|
||||
colour_id=colour_id,
|
||||
manufacturer_id=manufacturer_id,
|
||||
scope_model_id=scope_model_id,
|
||||
part_type_id=part_type_id,
|
||||
sub_part_type_id=sub_part_type_id,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
|
||||
return ap
|
||||
|
||||
|
||||
async def update(db: AsyncSession, ap: AugmentedPart, data: dict) -> AugmentedPart:
|
||||
for field, value in data.items():
|
||||
setattr(ap, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
|
||||
return ap
|
||||
|
||||
|
||||
async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
|
||||
"""ImportedParts that have no corresponding AugmentedPart link."""
|
||||
result = await db.execute(
|
||||
select(ImportedPart)
|
||||
.where(
|
||||
ImportedPart.InventoryID.not_in(
|
||||
select(augmented_part_imported_parts.c.imported_part_id)
|
||||
)
|
||||
)
|
||||
.order_by(ImportedPart.InventoryID)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
|
||||
await db.delete(ap)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,50 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
|
||||
|
||||
async def get(db: AsyncSession, clamp_connection_id: int) -> ClampConnection | None:
|
||||
return await db.get(ClampConnection, clamp_connection_id)
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[ClampConnection]:
|
||||
result = await db.execute(
|
||||
select(ClampConnection)
|
||||
.order_by(
|
||||
ClampConnection.min_mm.nulls_last(), ClampConnection.min_in.nulls_last()
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
min_mm: float | None,
|
||||
max_mm: float | None,
|
||||
min_in: float | None,
|
||||
max_in: float | None,
|
||||
) -> ClampConnection:
|
||||
cc = ClampConnection(min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in)
|
||||
db.add(cc)
|
||||
await db.commit()
|
||||
await db.refresh(cc)
|
||||
return cc
|
||||
|
||||
|
||||
async def update(db: AsyncSession, cc: ClampConnection, data: dict) -> ClampConnection:
|
||||
for field, value in data.items():
|
||||
setattr(cc, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(cc)
|
||||
return cc
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, cc: ClampConnection) -> None:
|
||||
await db.delete(cc)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,98 @@
|
||||
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_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,110 @@
|
||||
from sqlalchemy import distinct, insert, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imported_part import ImportedPart
|
||||
|
||||
_FIELDS = frozenset(
|
||||
{
|
||||
"InventoryID",
|
||||
"Description",
|
||||
"TBXColour",
|
||||
"ProductMaxThreadDepthmm",
|
||||
"ProductMaxThreadDepthin",
|
||||
"ProductODmm",
|
||||
"ProductODin",
|
||||
"TBXThreadAttribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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 = "",
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
) -> list[ImportedPart]:
|
||||
q = select(ImportedPart)
|
||||
if description.strip():
|
||||
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||
result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def distinct_colours(db: AsyncSession) -> list[str]:
|
||||
result = await db.execute(
|
||||
select(distinct(ImportedPart.TBXColour))
|
||||
.where(ImportedPart.TBXColour.isnot(None))
|
||||
.order_by(ImportedPart.TBXColour)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
async def count(db: AsyncSession) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
result = await db.execute(select(func.count()).select_from(ImportedPart))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def count_filtered(db: AsyncSession, description: str = "") -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
q = select(func.count()).select_from(ImportedPart)
|
||||
if description.strip():
|
||||
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, part: ImportedPart) -> None:
|
||||
await db.delete(part)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
|
||||
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
|
||||
if not records:
|
||||
return 0, 0
|
||||
|
||||
clean = [
|
||||
{k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS}
|
||||
for r in records
|
||||
if r.get("InventoryID")
|
||||
]
|
||||
if not clean:
|
||||
return 0, 0
|
||||
|
||||
incoming_ids = {r["InventoryID"] for r in clean}
|
||||
|
||||
result = await db.execute(
|
||||
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids))
|
||||
)
|
||||
existing_ids = {row[0] for row in result.all()}
|
||||
|
||||
to_insert = [r for r in clean if r["InventoryID"] not in existing_ids]
|
||||
to_update = [r for r in clean if r["InventoryID"] in existing_ids]
|
||||
|
||||
if to_insert:
|
||||
await db.execute(insert(ImportedPart), to_insert)
|
||||
|
||||
for record in to_update:
|
||||
inv_id = record["InventoryID"]
|
||||
vals = {k: v for k, v in record.items() if k != "InventoryID"}
|
||||
if vals:
|
||||
await db.execute(
|
||||
update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return len(to_insert), len(to_update)
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imported_part_config import ImportedPartConfig
|
||||
|
||||
_SINGLETON_ID = 1
|
||||
|
||||
|
||||
async def get(db: AsyncSession) -> ImportedPartConfig | None:
|
||||
return await db.get(ImportedPartConfig, _SINGLETON_ID)
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
odata_url: str | None,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
) -> ImportedPartConfig:
|
||||
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
|
||||
if config is None:
|
||||
config = ImportedPartConfig(
|
||||
id=_SINGLETON_ID,
|
||||
odata_url=odata_url,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
db.add(config)
|
||||
else:
|
||||
config.odata_url = odata_url
|
||||
config.username = username
|
||||
config.password = password
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.iso_thread import IsoThread
|
||||
|
||||
|
||||
async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
|
||||
return await db.get(IsoThread, iso_thread_id)
|
||||
|
||||
|
||||
async def get_by_values(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
pitch_mm: float,
|
||||
depth_mm: float | None,
|
||||
) -> IsoThread | None:
|
||||
depth_clause = (
|
||||
IsoThread.depth_mm.is_(None)
|
||||
if depth_mm is None
|
||||
else IsoThread.depth_mm == depth_mm
|
||||
)
|
||||
result = await db.execute(
|
||||
select(IsoThread).where(
|
||||
IsoThread.diameter_mm == diameter_mm,
|
||||
IsoThread.pitch_mm == pitch_mm,
|
||||
depth_clause,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[IsoThread]:
|
||||
result = await db.execute(
|
||||
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_mm: float,
|
||||
pitch_mm: float,
|
||||
depth_mm: float | None = None,
|
||||
) -> IsoThread:
|
||||
thread = IsoThread(diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=depth_mm)
|
||||
db.add(thread)
|
||||
await db.commit()
|
||||
await db.refresh(thread)
|
||||
return thread
|
||||
|
||||
|
||||
async def update(db: AsyncSession, thread: IsoThread, data: dict) -> IsoThread:
|
||||
for field, value in data.items():
|
||||
setattr(thread, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(thread)
|
||||
return thread
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, thread: IsoThread) -> None:
|
||||
await db.delete(thread)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,51 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.manufacturer import Manufacturer
|
||||
|
||||
|
||||
async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None:
|
||||
return await db.get(Manufacturer, manufacturer_id)
|
||||
|
||||
|
||||
async def get_by_name(db: AsyncSession, name: str) -> Manufacturer | None:
|
||||
result = await db.execute(select(Manufacturer).where(Manufacturer.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[Manufacturer]:
|
||||
result = await db.execute(
|
||||
select(Manufacturer).order_by(Manufacturer.name).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
url: str,
|
||||
logo_url: str | None = None,
|
||||
) -> Manufacturer:
|
||||
m = Manufacturer(name=name, url=url, logo_url=logo_url)
|
||||
db.add(m)
|
||||
await db.commit()
|
||||
await db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession, manufacturer: Manufacturer, data: dict
|
||||
) -> Manufacturer:
|
||||
for field, value in data.items():
|
||||
setattr(manufacturer, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(manufacturer)
|
||||
return manufacturer
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, manufacturer: Manufacturer) -> None:
|
||||
await db.delete(manufacturer)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.part_colour import PartColour
|
||||
|
||||
|
||||
async def get(db: AsyncSession, part_colour_id: int) -> PartColour | None:
|
||||
return await db.get(PartColour, part_colour_id)
|
||||
|
||||
|
||||
async def get_by_name(db: AsyncSession, name: str) -> PartColour | None:
|
||||
result = await db.execute(select(PartColour).where(PartColour.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[PartColour]:
|
||||
result = await db.execute(
|
||||
select(PartColour).order_by(PartColour.name).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, *, name: str) -> PartColour:
|
||||
pc = PartColour(name=name)
|
||||
db.add(pc)
|
||||
await db.commit()
|
||||
await db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
async def update(db: AsyncSession, pc: PartColour, data: dict) -> PartColour:
|
||||
for field, value in data.items():
|
||||
setattr(pc, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, pc: PartColour) -> None:
|
||||
await db.delete(pc)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.part_type import PartType
|
||||
|
||||
|
||||
async def get(db: AsyncSession, part_type_id: int) -> PartType | None:
|
||||
return await db.get(PartType, part_type_id)
|
||||
|
||||
|
||||
async def get_by_name(db: AsyncSession, name: str) -> PartType | None:
|
||||
result = await db.execute(select(PartType).where(PartType.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[PartType]:
|
||||
result = await db.execute(
|
||||
select(PartType).order_by(PartType.name).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, *, name: str) -> PartType:
|
||||
pt = PartType(name=name)
|
||||
db.add(pt)
|
||||
await db.commit()
|
||||
await db.refresh(pt)
|
||||
return pt
|
||||
|
||||
|
||||
async def update(db: AsyncSession, part_type: PartType, data: dict) -> PartType:
|
||||
for field, value in data.items():
|
||||
setattr(part_type, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(part_type)
|
||||
return part_type
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, part_type: PartType) -> None:
|
||||
await db.delete(part_type)
|
||||
await db.commit()
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.permission import Permission
|
||||
@@ -14,6 +15,23 @@ 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 +68,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,56 @@
|
||||
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 create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
augmented_part_id: int,
|
||||
magnification_min: float | None = None,
|
||||
magnification_max: float | None = None,
|
||||
model_suffix: str | None = None,
|
||||
objective_thread_pitch: str | None = None,
|
||||
) -> ScopeInfo:
|
||||
info = ScopeInfo(
|
||||
augmented_part_id=augmented_part_id,
|
||||
magnification_min=magnification_min,
|
||||
magnification_max=magnification_max,
|
||||
model_suffix=model_suffix,
|
||||
objective_thread_pitch=objective_thread_pitch,
|
||||
)
|
||||
db.add(info)
|
||||
await db.commit()
|
||||
await db.refresh(info)
|
||||
return info
|
||||
|
||||
|
||||
async def upsert(db: AsyncSession, *, augmented_part_id: int, **fields) -> ScopeInfo:
|
||||
"""Create the ScopeInfo for an AugmentedPart, or update it if it exists."""
|
||||
info = await get_by_augmented_part(db, augmented_part_id)
|
||||
if info is None:
|
||||
return await create(db, augmented_part_id=augmented_part_id, **fields)
|
||||
for field, value in fields.items():
|
||||
setattr(info, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(info)
|
||||
return info
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, info: ScopeInfo) -> None:
|
||||
await db.delete(info)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.scope_model import ScopeModel
|
||||
|
||||
|
||||
async def get(db: AsyncSession, scope_model_id: int) -> ScopeModel | None:
|
||||
return await db.get(ScopeModel, scope_model_id)
|
||||
|
||||
|
||||
async def get_by_name_and_manufacturer(
|
||||
db: AsyncSession, name: str, manufacturer_id: int
|
||||
) -> ScopeModel | None:
|
||||
result = await db.execute(
|
||||
select(ScopeModel).where(
|
||||
ScopeModel.name == name,
|
||||
ScopeModel.manufacturer_id == manufacturer_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[ScopeModel]:
|
||||
result = await db.execute(
|
||||
select(ScopeModel).order_by(ScopeModel.name).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
manufacturer_id: int,
|
||||
url: str | None = None,
|
||||
) -> ScopeModel:
|
||||
sm = ScopeModel(name=name, manufacturer_id=manufacturer_id, url=url)
|
||||
db.add(sm)
|
||||
await db.commit()
|
||||
await db.refresh(sm, attribute_names=["manufacturer"])
|
||||
return sm
|
||||
|
||||
|
||||
async def update(db: AsyncSession, scope_model: ScopeModel, data: dict) -> ScopeModel:
|
||||
for field, value in data.items():
|
||||
setattr(scope_model, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(scope_model, attribute_names=["manufacturer"])
|
||||
return scope_model
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, scope_model: ScopeModel) -> None:
|
||||
await db.delete(scope_model)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.static_connection import StaticConnection
|
||||
|
||||
|
||||
async def get(db: AsyncSession, static_connection_id: int) -> StaticConnection | None:
|
||||
return await db.get(StaticConnection, static_connection_id)
|
||||
|
||||
|
||||
async def get_by_od_mm(db: AsyncSession, od_mm: float) -> StaticConnection | None:
|
||||
result = await db.execute(
|
||||
select(StaticConnection).where(StaticConnection.od_mm == od_mm)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[StaticConnection]:
|
||||
result = await db.execute(
|
||||
select(StaticConnection)
|
||||
.order_by(StaticConnection.od_mm)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
od_mm: float,
|
||||
od_in: float,
|
||||
) -> StaticConnection:
|
||||
sc = StaticConnection(od_mm=od_mm, od_in=od_in)
|
||||
db.add(sc)
|
||||
await db.commit()
|
||||
await db.refresh(sc)
|
||||
return sc
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession, sc: StaticConnection, data: dict
|
||||
) -> StaticConnection:
|
||||
for field, value in data.items():
|
||||
setattr(sc, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(sc)
|
||||
return sc
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, sc: StaticConnection) -> None:
|
||||
await db.delete(sc)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,51 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sub_part_type import SubPartType
|
||||
|
||||
|
||||
async def get(db: AsyncSession, sub_part_type_id: int) -> SubPartType | None:
|
||||
return await db.get(SubPartType, sub_part_type_id)
|
||||
|
||||
|
||||
async def get_by_name_and_part_type(
|
||||
db: AsyncSession, name: str, part_type_id: int
|
||||
) -> SubPartType | None:
|
||||
result = await db.execute(
|
||||
select(SubPartType).where(
|
||||
SubPartType.name == name, SubPartType.part_type_id == part_type_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[SubPartType]:
|
||||
result = await db.execute(
|
||||
select(SubPartType).order_by(SubPartType.name).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, *, name: str, part_type_id: int) -> SubPartType:
|
||||
spt = SubPartType(name=name, part_type_id=part_type_id)
|
||||
db.add(spt)
|
||||
await db.commit()
|
||||
await db.refresh(spt, attribute_names=["part_type"])
|
||||
return spt
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession, sub_part_type: SubPartType, data: dict
|
||||
) -> SubPartType:
|
||||
for field, value in data.items():
|
||||
setattr(sub_part_type, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(sub_part_type, attribute_names=["part_type"])
|
||||
return sub_part_type
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, sub_part_type: SubPartType) -> None:
|
||||
await db.delete(sub_part_type)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,74 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.unc_thread import UncSeries, UncThread
|
||||
|
||||
|
||||
async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
|
||||
return await db.get(UncThread, unc_thread_id)
|
||||
|
||||
|
||||
async def get_by_values(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_fraction: str,
|
||||
tpi: int,
|
||||
series: UncSeries,
|
||||
depth_in: float | None,
|
||||
) -> UncThread | None:
|
||||
depth_clause = (
|
||||
UncThread.depth_in.is_(None)
|
||||
if depth_in is None
|
||||
else UncThread.depth_in == depth_in
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UncThread).where(
|
||||
UncThread.diameter_fraction == diameter_fraction,
|
||||
UncThread.tpi == tpi,
|
||||
UncThread.series == series,
|
||||
depth_clause,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession, skip: int = 0, limit: int = 100
|
||||
) -> list[UncThread]:
|
||||
result = await db.execute(
|
||||
select(UncThread).order_by(UncThread.id).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
diameter_fraction: str,
|
||||
tpi: int,
|
||||
series: UncSeries,
|
||||
depth_in: float | None = None,
|
||||
) -> UncThread:
|
||||
thread = UncThread(
|
||||
diameter_fraction=diameter_fraction,
|
||||
tpi=tpi,
|
||||
series=series,
|
||||
depth_in=depth_in,
|
||||
)
|
||||
db.add(thread)
|
||||
await db.commit()
|
||||
await db.refresh(thread)
|
||||
return thread
|
||||
|
||||
|
||||
async def update(db: AsyncSession, thread: UncThread, data: dict) -> UncThread:
|
||||
for field, value in data.items():
|
||||
setattr(thread, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(thread)
|
||||
return thread
|
||||
|
||||
|
||||
async def delete(db: AsyncSession, thread: UncThread) -> None:
|
||||
await db.delete(thread)
|
||||
await db.commit()
|
||||
+39
-46
@@ -1,9 +1,9 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password, make_unusable_password, verify_password
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.models.role import Role
|
||||
from app.models.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())
|
||||
@@ -32,29 +37,24 @@ async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _resolve_roles_by_name(db: AsyncSession, names: list[str]) -> list[Role]:
|
||||
if not names:
|
||||
return []
|
||||
result = await db.execute(select(Role).where(Role.name.in_(names)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
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,
|
||||
auth_provider: str = "local",
|
||||
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,
|
||||
auth_provider=auth_provider,
|
||||
roles=await _resolve_roles(db, role_ids or []),
|
||||
)
|
||||
db.add(user)
|
||||
@@ -63,45 +63,35 @@ async def create(
|
||||
return user
|
||||
|
||||
|
||||
async def upsert_ldap_user(
|
||||
async def get_or_create_ldap_user(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
username: str,
|
||||
email: str,
|
||||
full_name: str | None = None,
|
||||
default_role_names: list[str] | None = None,
|
||||
full_name: str | None,
|
||||
role_ids: list[int] | None = None,
|
||||
) -> User:
|
||||
"""Provision (or refresh) the local row for an LDAP-authenticated user.
|
||||
"""Find an LDAP user by username, provisioning a local profile on first login.
|
||||
|
||||
On first login the user is created with an unusable local password (so
|
||||
password auth can never succeed) and the configured default roles. On later
|
||||
logins the existing row is reused; only ``full_name`` is synced from the
|
||||
directory. Roles assigned afterwards in the admin UI are preserved.
|
||||
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_email(db, email)
|
||||
if user is None:
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=make_unusable_password(),
|
||||
full_name=full_name,
|
||||
auth_provider="ldap",
|
||||
roles=await _resolve_roles_by_name(db, default_role_names or []),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
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
|
||||
|
||||
changed = False
|
||||
if user.auth_provider != "ldap":
|
||||
user.auth_provider = "ldap"
|
||||
changed = True
|
||||
if full_name and user.full_name != full_name:
|
||||
user.full_name = full_name
|
||||
changed = True
|
||||
if changed:
|
||||
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:
|
||||
@@ -131,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
|
||||
+42
-1
@@ -1,16 +1,57 @@
|
||||
"""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
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
from app.models.fit import Fit, FitLabel, FitNote
|
||||
from app.models.imported_part import ImportedPart
|
||||
from app.models.imported_part_config import ImportedPartConfig
|
||||
from app.models.iso_thread import IsoThread
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
from app.models.permission import Permission
|
||||
from app.models.scope_info import ScopeInfo
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.static_connection import StaticConnection
|
||||
from app.models.sub_part_type import SubPartType
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.role import Role
|
||||
from app.models.unc_thread import UncThread
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"AttachmentPoint",
|
||||
"AttachmentPointName",
|
||||
"AuditLog",
|
||||
"AugmentedPart",
|
||||
"ClampConnection",
|
||||
"Fit",
|
||||
"FitLabel",
|
||||
"FitNote",
|
||||
"ImportedPart",
|
||||
"ImportedPartConfig",
|
||||
"IsoThread",
|
||||
"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,46 @@
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.attachment_point_name import AttachmentPointName
|
||||
from app.models.clamp_connection import ClampConnection
|
||||
from app.models.iso_thread import IsoThread
|
||||
from app.models.static_connection import StaticConnection
|
||||
from app.models.unc_thread import UncThread
|
||||
|
||||
|
||||
class AttachmentPoint(Base):
|
||||
__tablename__ = "attachment_points"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
augmented_part_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
attachment_point_name_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
# Exactly one of the four below must be non-null (enforced in application layer)
|
||||
iso_thread_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
|
||||
)
|
||||
unc_thread_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("unc_threads.id", ondelete="RESTRICT"), nullable=True, default=None
|
||||
)
|
||||
clamp_connection_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("clamp_connections.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
static_connection_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("static_connections.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
attachment_point_name: Mapped[AttachmentPointName] = relationship(lazy="selectin")
|
||||
iso_thread: Mapped[IsoThread | None] = relationship(lazy="selectin")
|
||||
unc_thread: Mapped[UncThread | None] = relationship(lazy="selectin")
|
||||
clamp_connection: Mapped[ClampConnection | None] = relationship(lazy="selectin")
|
||||
static_connection: Mapped[StaticConnection | None] = relationship(lazy="selectin")
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AttachmentPointName(Base):
|
||||
__tablename__ = "attachment_point_names"
|
||||
__table_args__ = (UniqueConstraint("name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
user_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
resource_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
description: Mapped[str] = mapped_column(nullable=False)
|
||||
@@ -0,0 +1,47 @@
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.associations import augmented_part_imported_parts
|
||||
from app.models.imported_part import ImportedPart
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.sub_part_type import SubPartType
|
||||
|
||||
|
||||
class AugmentedPart(Base):
|
||||
__tablename__ = "augmented_parts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
description: Mapped[str] = mapped_column(String, nullable=False)
|
||||
colour_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
manufacturer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
scope_model_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("scope_models.id", ondelete="SET NULL"), nullable=True, default=None
|
||||
)
|
||||
part_type_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
sub_part_type_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("sub_part_types.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
# linked imported parts — not eagerly loaded; used by sync service only
|
||||
imported_parts: Mapped[list[ImportedPart]] = relationship(
|
||||
secondary=augmented_part_imported_parts, lazy="select"
|
||||
)
|
||||
|
||||
colour: Mapped[PartColour] = relationship(lazy="selectin")
|
||||
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
|
||||
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
|
||||
part_type: Mapped[PartType] = relationship(lazy="selectin")
|
||||
sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin")
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ClampConnection(Base):
|
||||
__tablename__ = "clamp_connections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
min_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
max_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
min_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
max_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
@@ -0,0 +1,89 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FitLabel(str, enum.Enum):
|
||||
POSSIBLE = "possible"
|
||||
EXACT = "exact"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class Fit(Base):
|
||||
"""A candidate or confirmed fit between two attachment points on different parts."""
|
||||
|
||||
__tablename__ = "fits"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Canonical ordering (attachment_point_a_id < attachment_point_b_id) is enforced
|
||||
# by app.crud.fit so an unordered pair only ever has one row.
|
||||
attachment_point_a_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
attachment_point_b_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("attachment_points.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Denormalized from the attachment points' augmented_part_id, for querying
|
||||
# "fits for this part" without joining through attachment_points.
|
||||
augmented_part_a_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
augmented_part_b_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
label: Mapped[FitLabel] = mapped_column(
|
||||
Enum(FitLabel, values_callable=lambda enum_cls: [e.value for e in enum_cls]),
|
||||
nullable=False,
|
||||
default=FitLabel.POSSIBLE,
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
attachment_point_a = relationship(
|
||||
"AttachmentPoint", foreign_keys=[attachment_point_a_id], lazy="selectin"
|
||||
)
|
||||
attachment_point_b = relationship(
|
||||
"AttachmentPoint", foreign_keys=[attachment_point_b_id], lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list["FitNote"]] = relationship(
|
||||
back_populates="fit",
|
||||
lazy="selectin",
|
||||
order_by="FitNote.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class FitNote(Base):
|
||||
__tablename__ = "fit_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
fit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("fits.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Denormalized snapshot of who wrote the note (mirrors AuditLog.user_email),
|
||||
# so the note stays attributable even if the user is later deleted.
|
||||
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
fit: Mapped[Fit] = relationship(back_populates="notes")
|
||||
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ImportedPart(Base):
|
||||
__tablename__ = "imported_parts"
|
||||
|
||||
InventoryID: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
Description: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
TBXColour: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
ProductMaxThreadDepthmm: Mapped[str | None] = mapped_column(
|
||||
nullable=True, default=None
|
||||
)
|
||||
ProductMaxThreadDepthin: Mapped[str | None] = mapped_column(
|
||||
nullable=True, default=None
|
||||
)
|
||||
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ImportedPartConfig(Base):
|
||||
"""Singleton (id=1) storing the OData source URL and credentials."""
|
||||
|
||||
__tablename__ = "imported_part_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||
odata_url: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
username: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
password: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class IsoThread(Base):
|
||||
__tablename__ = "iso_threads"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
diameter_mm: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
pitch_mm: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
depth_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Manufacturer(Base):
|
||||
__tablename__ = "manufacturers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
url: Mapped[str] = mapped_column(nullable=False)
|
||||
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PartColour(Base):
|
||||
__tablename__ = "part_colours"
|
||||
__table_args__ = (UniqueConstraint("name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
@@ -0,0 +1,10 @@
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PartType(Base):
|
||||
__tablename__ = "part_types"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False, unique=True)
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from 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
|
||||
)
|
||||
# Kept as the raw string from the sheet (e.g. "0.75", "1/16") so no precision
|
||||
# or notation is lost; the parsed thread lives on its own AttachmentPoint.
|
||||
objective_thread_pitch: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, default=None
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.manufacturer import Manufacturer
|
||||
|
||||
|
||||
class ScopeModel(Base):
|
||||
__tablename__ = "scope_models"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
manufacturer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
|
||||
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Float, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StaticConnection(Base):
|
||||
__tablename__ = "static_connections"
|
||||
__table_args__ = (UniqueConstraint("od_mm"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
od_mm: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
od_in: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.part_type import PartType
|
||||
|
||||
|
||||
class SubPartType(Base):
|
||||
__tablename__ = "sub_part_types"
|
||||
__table_args__ = (UniqueConstraint("name", "part_type_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
part_type_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
part_type: Mapped[PartType] = relationship(lazy="selectin")
|
||||
@@ -0,0 +1,24 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Enum, Float, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UncSeries(str, enum.Enum):
|
||||
UNC = "UNC"
|
||||
UNF = "UNF"
|
||||
UNEF = "UNEF"
|
||||
UN = "UN"
|
||||
UNS = "UNS"
|
||||
|
||||
|
||||
class UncThread(Base):
|
||||
__tablename__ = "unc_threads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
diameter_fraction: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
tpi: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
series: Mapped[UncSeries] = mapped_column(Enum(UncSeries), nullable=False)
|
||||
depth_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
+11
-5
@@ -6,20 +6,26 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
from app.models.associations import user_roles
|
||||
|
||||
# Where a user's credentials are verified.
|
||||
AUTH_SOURCE_LOCAL = "local"
|
||||
AUTH_SOURCE_LDAP = "ldap"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
# Set for users provisioned from LDAP; how they sign in. Null for local users.
|
||||
username: Mapped[str | None] = mapped_column(
|
||||
String(255), unique=True, index=True, default=None
|
||||
)
|
||||
# Null for LDAP users, who have no local password (verified against LDAP).
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
auth_source: Mapped[str] = mapped_column(String(16), default=AUTH_SOURCE_LOCAL)
|
||||
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Where this account authenticates: "local" (password) or "ldap".
|
||||
auth_provider: Mapped[str] = mapped_column(
|
||||
String(20), default="local", server_default="local"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.attachment_point_name import AttachmentPointNameRead
|
||||
from app.schemas.clamp_connection import ClampConnectionRead
|
||||
from app.schemas.iso_thread import IsoThreadRead
|
||||
from app.schemas.static_connection import StaticConnectionRead
|
||||
from app.schemas.unc_thread import UncThreadRead
|
||||
|
||||
|
||||
class AttachmentPointBase(BaseModel):
|
||||
augmented_part_id: int
|
||||
attachment_point_name_id: int
|
||||
iso_thread_id: int | None = None
|
||||
unc_thread_id: int | None = None
|
||||
clamp_connection_id: int | None = None
|
||||
static_connection_id: int | None = None
|
||||
|
||||
|
||||
class AttachmentPointCreate(AttachmentPointBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttachmentPointUpdate(BaseModel):
|
||||
augmented_part_id: int | None = None
|
||||
attachment_point_name_id: int | None = None
|
||||
iso_thread_id: int | None = None
|
||||
unc_thread_id: int | None = None
|
||||
clamp_connection_id: int | None = None
|
||||
static_connection_id: int | None = None
|
||||
|
||||
|
||||
class AttachmentPointRead(AttachmentPointBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
attachment_point_name: AttachmentPointNameRead
|
||||
iso_thread: IsoThreadRead | None = None
|
||||
unc_thread: UncThreadRead | None = None
|
||||
clamp_connection: ClampConnectionRead | None = None
|
||||
static_connection: StaticConnectionRead | None = None
|
||||
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AttachmentPointNameBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class AttachmentPointNameCreate(AttachmentPointNameBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttachmentPointNameUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class AttachmentPointNameRead(AttachmentPointNameBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
user_id: int | None
|
||||
user_email: str
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: int | None
|
||||
description: str
|
||||
@@ -0,0 +1,48 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.manufacturer import ManufacturerRead
|
||||
from app.schemas.part_colour import PartColourRead
|
||||
from app.schemas.part_type import PartTypeRead
|
||||
from app.schemas.scope_model import ScopeModelRead
|
||||
from app.schemas.sub_part_type import SubPartTypeRead
|
||||
|
||||
|
||||
class AugmentedPartBase(BaseModel):
|
||||
sku: str | None = None
|
||||
description: str
|
||||
colour_id: int
|
||||
manufacturer_id: int
|
||||
scope_model_id: int | None = None
|
||||
part_type_id: int
|
||||
sub_part_type_id: int | None = None
|
||||
|
||||
|
||||
class AugmentedPartCreate(AugmentedPartBase):
|
||||
pass
|
||||
|
||||
|
||||
class AugmentedPartUpdate(BaseModel):
|
||||
sku: str | None = None
|
||||
description: str | None = None
|
||||
colour_id: int | None = None
|
||||
manufacturer_id: int | None = None
|
||||
scope_model_id: int | None = None
|
||||
part_type_id: int | None = None
|
||||
sub_part_type_id: int | None = None
|
||||
|
||||
|
||||
class AugmentedPartSyncResult(BaseModel):
|
||||
created: int
|
||||
deleted: int
|
||||
skipped: int
|
||||
|
||||
|
||||
class AugmentedPartRead(AugmentedPartBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
colour: PartColourRead
|
||||
manufacturer: ManufacturerRead
|
||||
scope_model: ScopeModelRead | None
|
||||
part_type: PartTypeRead
|
||||
sub_part_type: SubPartTypeRead | None
|
||||
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ClampConnectionBase(BaseModel):
|
||||
min_mm: float | None = None
|
||||
max_mm: float | None = None
|
||||
min_in: float | None = None
|
||||
max_in: float | None = None
|
||||
|
||||
|
||||
class ClampConnectionCreate(ClampConnectionBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClampConnectionUpdate(ClampConnectionBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClampConnectionRead(ClampConnectionBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from app.models.fit import FitLabel
|
||||
from app.schemas.attachment_point import AttachmentPointRead
|
||||
|
||||
|
||||
class FitNoteCreate(BaseModel):
|
||||
body: str
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def validate_body(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("body must not be blank")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class FitNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
fit_id: int
|
||||
user_id: int | None
|
||||
username: str
|
||||
body: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FitCreate(BaseModel):
|
||||
"""Manually-declared fit; recorded as Exact regardless of the match algorithm."""
|
||||
|
||||
attachment_point_a_id: int
|
||||
attachment_point_b_id: int
|
||||
|
||||
|
||||
class FitUpdate(BaseModel):
|
||||
label: FitLabel
|
||||
|
||||
|
||||
class FitRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
augmented_part_a_id: int
|
||||
augmented_part_b_id: int
|
||||
attachment_point_a_id: int
|
||||
attachment_point_b_id: int
|
||||
attachment_point_a: AttachmentPointRead
|
||||
attachment_point_b: AttachmentPointRead
|
||||
label: FitLabel
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[FitNoteRead] = []
|
||||
|
||||
|
||||
class FitRecomputeResult(BaseModel):
|
||||
created: int
|
||||
removed: int
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ImportedPartRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
InventoryID: str
|
||||
Description: str | None
|
||||
TBXColour: str | None
|
||||
ProductMaxThreadDepthmm: str | None
|
||||
ProductMaxThreadDepthin: str | None
|
||||
ProductODmm: str | None
|
||||
ProductODin: str | None
|
||||
TBXThreadAttribute: str | None
|
||||
|
||||
|
||||
class ImportedPartConfigRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
odata_url: str | None
|
||||
username: str | None
|
||||
has_password: bool
|
||||
|
||||
|
||||
class ImportedPartConfigUpdate(BaseModel):
|
||||
odata_url: str | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
total: int
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class IsoThreadBase(BaseModel):
|
||||
diameter_mm: float = Field(..., gt=0, description="Nominal diameter in mm")
|
||||
pitch_mm: float = Field(..., gt=0, description="Thread pitch in mm")
|
||||
depth_mm: float | None = Field(None, gt=0, description="Thread depth in mm")
|
||||
|
||||
|
||||
class IsoThreadCreate(IsoThreadBase):
|
||||
pass
|
||||
|
||||
|
||||
class IsoThreadUpdate(BaseModel):
|
||||
diameter_mm: float | None = Field(None, gt=0)
|
||||
pitch_mm: float | None = Field(None, gt=0)
|
||||
depth_mm: float | None = Field(None, gt=0)
|
||||
|
||||
|
||||
class IsoThreadRead(IsoThreadBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel, ConfigDict, HttpUrl
|
||||
|
||||
|
||||
class ManufacturerBase(BaseModel):
|
||||
name: str
|
||||
url: HttpUrl
|
||||
logo_url: HttpUrl | None = None
|
||||
|
||||
|
||||
class ManufacturerCreate(ManufacturerBase):
|
||||
pass
|
||||
|
||||
|
||||
class ManufacturerUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
url: HttpUrl | None = None
|
||||
logo_url: HttpUrl | None = None
|
||||
|
||||
|
||||
class ManufacturerRead(ManufacturerBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PartColourBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PartColourCreate(PartColourBase):
|
||||
pass
|
||||
|
||||
|
||||
class PartColourUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class PartColourRead(PartColourBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PartTypeBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PartTypeCreate(PartTypeBase):
|
||||
pass
|
||||
|
||||
|
||||
class PartTypeUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class PartTypeRead(PartTypeBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user