Compare commits

..
1 Commits
Author SHA1 Message Date
Ian Roddis f2b99089ea Adding LDAP support 2026-07-19 12:02:41 -03:00
203 changed files with 613 additions and 12524 deletions

No files matched your search

-17
View File
@@ -1,17 +0,0 @@
{
"permissions": {
"allow": [
"Bash(uv run *)",
"Bash(uv sync *)",
"Bash(git stash *)",
"Bash(python3 *)",
"Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")",
"Bash(PYTHONPATH=. uv run *)",
"Bash(python *)",
"Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)",
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
"Bash(docker build *)",
"Bash(git status *)"
]
}
}
-49
View File
@@ -1,49 +0,0 @@
# 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
+27 -16
View File
@@ -2,7 +2,7 @@
# environment variable (which takes precedence over the .env file). # environment variable (which takes precedence over the .env file).
# --- General --- # --- General ---
APP_NAME="Armament FitGuide" APP_NAME="FastAPI Skeleton"
DEBUG=false DEBUG=false
# --- Security --- # --- Security ---
@@ -26,24 +26,35 @@ COOKIE_NAME=access_token
# Set true when serving over HTTPS. # Set true when serving over HTTPS.
COOKIE_SECURE=false COOKIE_SECURE=false
# --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
# username + password. On first login a local profile is provisioned using the
# username and the default role (mark one in the Roles admin UI).
LDAP_ENABLED=false
LDAP_HOST=localhost
LDAP_PORT=389
LDAP_USE_SSL=false
# "{username}" is substituted with the submitted username to form the bind DN.
LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=example.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) --- # --- CORS (JSON list) ---
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
# --- LDAP / Active Directory (optional) ---
# When enabled, login credentials are validated against the directory and a
# local user is provisioned on first login. Local DB auth still works as a
# fallback (so the seeded superuser can log in). Disabled by default.
LDAP_ENABLED=false
LDAP_SERVER_URI="ldap://ldap.example.com:389"
LDAP_USE_SSL=false
LDAP_START_TLS=false
LDAP_TLS_VALIDATE=true
# LDAP_CA_CERTS_FILE="/etc/ssl/certs/ca.pem"
# Option 1 — direct bind: build the user DN from the login name.
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Option 2 — search + bind: a service account finds the user, then we bind as
# the found DN. Leave the template above empty to use this mode.
# LDAP_BIND_DN="cn=svc-reader,dc=example,dc=com"
# LDAP_BIND_PASSWORD="service-account-password"
# LDAP_USER_SEARCH_BASE="ou=people,dc=example,dc=com"
# LDAP_USER_SEARCH_FILTER="(uid={username})"
# Directory attributes copied onto the local user, and roles granted on first login.
LDAP_ATTR_EMAIL=mail
LDAP_ATTR_FULL_NAME=cn
LDAP_DEFAULT_ROLES=["user"]
# --- Bootstrap superuser (used by scripts/seed.py) --- # --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@example.com FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethis FIRST_SUPERUSER_PASSWORD=changethis
-2
View File
@@ -10,8 +10,6 @@ venv/
# Databases # Databases
*.db *.db
*.db-shm
*.db-wal
*.sqlite3 *.sqlite3
# Test / tooling # Test / tooling
+7
View File
@@ -44,6 +44,13 @@ 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 - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`. failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
- **Credential verification** goes through `services/auth.authenticate(db, username,
password)` — both login routes call it, not `crud.user.authenticate` directly.
When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`,
sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local
row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`,
`LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in
`settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`.
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the - **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. `refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
+2 -5
View File
@@ -18,11 +18,8 @@ RUN uv sync --no-dev
# Copy the application. # Copy the application.
COPY . . COPY . .
RUN chmod +x entrypoint.sh
# If sqlite3 is used
VOLUME /data
EXPOSE 8000 EXPOSE 8000
# Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server. # Default: run the API server. docker-compose overrides this to also seed.
ENTRYPOINT ["./entrypoint.sh"] CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-23
View File
@@ -1,23 +0,0 @@
- 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
-20
View File
@@ -1,11 +1,7 @@
VERSION := $(shell git describe --tags --always)
IMAGE_NAME := fitguide
sync: sync:
uv sync --extra dev uv sync --extra dev
seed: seed:
uv run alembic upgrade head
uv run python -m scripts.seed uv run python -m scripts.seed
dev: dev:
@@ -21,19 +17,3 @@ migrate:
fmt: fmt:
uv run black . uv run black .
clean:
rm app.db
uv run alembic upgrade head
uv run python -m scripts.seed
docker:
mkdir -p artifacts
docker build -t $(IMAGE_NAME):$(VERSION) .
docker-push: docker
docker tag $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
+36 -13
View File
@@ -4,19 +4,19 @@ A production-shaped starting point for a web app that serves both **HTML (HTMX)*
## Stack ## Stack
| Concern | Choice | | Concern | Choice |
|----------------|--------| | -------------------- | ----------------------------------------------------------- |
| Web framework | FastAPI | | Web framework | FastAPI |
| HTML / interactivity | Jinja2 + HTMX | | HTML / interactivity | Jinja2 + HTMX |
| ORM | SQLAlchemy 2.0 (async) | | ORM | SQLAlchemy 2.0 (async) |
| Migrations | Alembic | | Migrations | Alembic |
| Database | SQLite (default) or Postgres — one `DATABASE_URL` | | Database | SQLite (default) or Postgres — one `DATABASE_URL` |
| Auth | JWT access + refresh tokens, rotation, DB-backed revocation | | Auth | JWT access + refresh tokens, rotation, DB-backed revocation |
| Authorization | RBAC: users → roles → permissions | | Authorization | RBAC: users → roles → permissions |
| Admin UI | Custom HTMX dashboard (user & role CRUD) | | Admin UI | Custom HTMX dashboard (user & role CRUD) |
| Config | env vars **or** `.env` (pydantic-settings) | | Config | env vars **or** `.env` (pydantic-settings) |
| Tooling | uv, pytest, ruff | | Tooling | uv, pytest, ruff |
| Packaging | Dockerfile + docker-compose | | Packaging | Dockerfile + docker-compose |
## Layout ## Layout
@@ -154,6 +154,7 @@ Every setting is read from an environment variable first, then `.env`
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
| `LDAP_ENABLED` | `false` | validate logins against LDAP/AD (see below) |
## Auth model ## Auth model
@@ -168,6 +169,28 @@ stores the access token in an httpOnly cookie so pages work without client JS.
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked` Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
flag, so logout and rotation genuinely invalidate them. flag, so logout and rotation genuinely invalidate them.
### LDAP / Active Directory (optional)
Set `LDAP_ENABLED=true` to validate logins against a directory. On a successful
bind, a local user is **provisioned on first login** (with an unusable local
password and the roles in `LDAP_DEFAULT_ROLES`) so RBAC, tokens, and the admin
UI work unchanged. Existing accounts still authenticate against the local
database as a fallback, so the seeded superuser keeps working.
Two lookup modes (configured in `.env`, see `.env.example`):
- **Direct bind** — set `LDAP_BIND_DN_TEMPLATE`, e.g.
`uid={username},ou=people,dc=example,dc=com`; the login name is substituted
and used to bind directly.
- **Search + bind** — leave the template empty and set `LDAP_BIND_DN` /
`LDAP_BIND_PASSWORD` (a service account) plus `LDAP_USER_SEARCH_BASE` /
`LDAP_USER_SEARCH_FILTER`; the user is located, then re-bound to verify the
password.
The local user's email/name come from the `LDAP_ATTR_EMAIL` / `LDAP_ATTR_FULL_NAME`
attributes. Use `ldaps://` (`LDAP_USE_SSL=true`) or `LDAP_START_TLS=true` in
production so credentials aren't sent in cleartext.
## RBAC ## RBAC
`users → roles → permissions`. Permissions follow `resource:action` `users → roles → permissions`. Permissions follow `resource:action`
@@ -0,0 +1,33 @@
"""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")
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,72 +0,0 @@
"""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")
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,42 +0,0 @@
"""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")
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,52 +0,0 @@
"""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 ###
@@ -1,39 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
@@ -1,54 +0,0 @@
"""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")
@@ -1,38 +0,0 @@
"""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")
@@ -1,35 +0,0 @@
"""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 ###
@@ -1,29 +0,0 @@
"""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 ###
-101
View File
@@ -1,101 +0,0 @@
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),
)
-131
View File
@@ -1,131 +0,0 @@
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
),
)
-19
View File
@@ -1,19 +0,0 @@
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)
-134
View File
@@ -1,134 +0,0 @@
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
),
)
+11 -35
View File
@@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import role as role_crud from app.crud import role as role_crud
from app.crud import token as token_crud from app.crud import token as token_crud
@@ -11,34 +10,32 @@ from app.deps import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead from app.schemas.user import RegisterRequest, UserRead
from app.services import ldap as ldap_service from app.services.auth import (
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token RefreshError,
authenticate,
issue_token_pair,
rotate_refresh_token,
)
router = APIRouter() router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user" DEFAULT_USER_ROLE = "user"
async def _default_role_ids(db: AsyncSession) -> list[int]:
role = await role_crud.get_default(db)
if role is None:
role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
return [role.id] if role else []
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email): if await user_crud.get_by_email(db, payload.email):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered" status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
) )
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create( user = await user_crud.create(
db, db,
email=payload.email, email=payload.email,
password=payload.password, password=payload.password,
full_name=payload.full_name, full_name=payload.full_name,
role_ids=await _default_role_ids(db), role_ids=role_ids,
) )
return user return user
@@ -48,8 +45,8 @@ async def login(
form: OAuth2PasswordRequestForm = Depends(), form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# OAuth2 form uses ``username``; we treat it as the email. # OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
user = await user_crud.authenticate(db, form.username, form.password) user = await authenticate(db, form.username, form.password)
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -60,27 +57,6 @@ async def login(
return Token(access_token=access, refresh_token=refresh) return Token(access_token=access, refresh_token=refresh)
@router.post("/ldap-login", response_model=Token)
async def ldap_login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
"""Authenticate against LDAP, provisioning the local profile on first login."""
if not settings.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="LDAP login is not enabled"
)
user = await ldap_service.login(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token) @router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)): async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try: try:
-121
View File
@@ -1,121 +0,0 @@
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
View File
@@ -1,171 +0,0 @@
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)
-139
View File
@@ -1,139 +0,0 @@
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),
)
-108
View File
@@ -1,108 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import iso_thread as iso_thread_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.iso_thread import IsoThreadCreate, IsoThreadRead, IsoThreadUpdate
router = APIRouter()
@router.get("", response_model=list[IsoThreadRead])
async def list_iso_threads(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
return await iso_thread_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=IsoThreadRead, status_code=status.HTTP_201_CREATED)
async def create_iso_thread(
payload: IsoThreadCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("iso_threads:write")),
):
thread = await iso_thread_crud.create(
db,
diameter_mm=payload.diameter_mm,
pitch_mm=payload.pitch_mm,
depth_mm=payload.depth_mm,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_create(
"IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"
),
)
return thread
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
async def get_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:read")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
return thread
@router.patch("/{iso_thread_id}", response_model=IsoThreadRead)
async def update_iso_thread(
iso_thread_id: int,
payload: IsoThreadUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
changes = payload.model_dump(exclude_unset=True)
thread = await iso_thread_crud.update(db, thread, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_update("IsoThread", thread.id, changes),
)
return thread
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("iso_threads:delete")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
name = f"M{thread.diameter_mm}×{thread.pitch_mm}"
await iso_thread_crud.delete(db, thread)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="IsoThread",
resource_id=iso_thread_id,
description=audit_crud.describe_delete("IsoThread", iso_thread_id, name),
)
-114
View File
@@ -1,114 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import manufacturer as manufacturer_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.manufacturer import (
ManufacturerCreate,
ManufacturerRead,
ManufacturerUpdate,
)
router = APIRouter()
@router.get("", response_model=list[ManufacturerRead])
async def list_manufacturers(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
return await manufacturer_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ManufacturerRead, status_code=status.HTTP_201_CREATED)
async def create_manufacturer(
payload: ManufacturerCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.create(
db,
name=payload.name,
url=str(payload.url),
logo_url=str(payload.logo_url) if payload.logo_url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_create("Manufacturer", m.id, m.name),
)
return m
@router.get("/{manufacturer_id}", response_model=ManufacturerRead)
async def get_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
return m
@router.patch("/{manufacturer_id}", response_model=ManufacturerRead)
async def update_manufacturer(
manufacturer_id: int,
payload: ManufacturerUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
if "logo_url" in data and data["logo_url"] is not None:
data["logo_url"] = str(data["logo_url"])
m = await manufacturer_crud.update(db, m, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_update("Manufacturer", m.id, data),
)
return m
@router.delete("/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:delete")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
name = m.name
await manufacturer_crud.delete(db, m)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Manufacturer",
resource_id=manufacturer_id,
description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name),
)
-95
View File
@@ -1,95 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import part_colour as part_colour_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.part_colour import PartColourCreate, PartColourRead, PartColourUpdate
router = APIRouter()
@router.get("", response_model=list[PartColourRead])
async def list_part_colours(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
return await part_colour_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=PartColourRead, status_code=status.HTTP_201_CREATED)
async def create_part_colour(
payload: PartColourCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_create("PartColour", pc.id, pc.name),
)
return pc
@router.get("/{part_colour_id}", response_model=PartColourRead)
async def get_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
return pc
@router.patch("/{part_colour_id}", response_model=PartColourRead)
async def update_part_colour(
part_colour_id: int,
payload: PartColourUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
changes = payload.model_dump(exclude_unset=True)
pc = await part_colour_crud.update(db, pc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_update("PartColour", pc.id, changes),
)
return pc
@router.delete("/{part_colour_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:delete")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
name = pc.name
await part_colour_crud.delete(db, pc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="PartColour",
resource_id=part_colour_id,
description=audit_crud.describe_delete("PartColour", part_colour_id, name),
)
-101
View File
@@ -1,101 +0,0 @@
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),
)
+5 -34
View File
@@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import role as role_crud from app.crud import role as role_crud
from app.deps import require_permission from app.deps import require_permission
from app.models.user import User from app.models.user import User
@@ -23,27 +22,18 @@ async def list_roles(
async def create_role( async def create_role(
payload: RoleCreate, payload: RoleCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("roles:write")), _: User = Depends(require_permission("roles:write")),
): ):
if await role_crud.get_by_name(db, payload.name): if await role_crud.get_by_name(db, payload.name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists" status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
) )
role = await role_crud.create( return await role_crud.create(
db, db,
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
permission_ids=payload.permission_ids, permission_ids=payload.permission_ids,
) )
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_create("Role", role.id, role.name),
)
return role
@router.get("/permissions", response_model=list[PermissionRead]) @router.get("/permissions", response_model=list[PermissionRead])
@@ -73,44 +63,25 @@ async def update_role(
role_id: int, role_id: int,
payload: RoleUpdate, payload: RoleUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("roles:write")), _: User = Depends(require_permission("roles:write")),
): ):
role = await role_crud.get(db, role_id) role = await role_crud.get(db, role_id)
if role is None: if role is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
) )
changes = payload.model_dump(exclude_unset=True) return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
role = await role_crud.update(db, role, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_update("Role", role.id, changes),
)
return role
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role( async def delete_role(
role_id: int, role_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("roles:write")), _: User = Depends(require_permission("roles:write")),
): ):
role = await role_crud.get(db, role_id) role = await role_crud.get(db, role_id)
if role is None: if role is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
) )
name = role.name
await role_crud.delete(db, role) await role_crud.delete(db, role)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Role",
resource_id=role_id,
description=audit_crud.describe_delete("Role", role_id, name),
)
+1 -65
View File
@@ -1,72 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import ( from app.api import auth, roles, users
attachment_point_names,
attachment_points,
audit_log,
augmented_parts,
auth,
clamp_connections,
fits,
imported_parts,
iso_threads,
manufacturers,
part_colours,
part_types,
roles,
scope_import,
scope_models,
static_connections,
sub_part_types,
unc_threads,
users,
)
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(
iso_threads.router, prefix="/iso-threads", tags=["iso-threads"]
)
api_router.include_router(
unc_threads.router, prefix="/unc-threads", tags=["unc-threads"]
)
api_router.include_router(
manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]
)
api_router.include_router(
scope_models.router, prefix="/scope-models", tags=["scope-models"]
)
api_router.include_router(
part_colours.router, prefix="/part-colours", tags=["part-colours"]
)
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
api_router.include_router(
sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]
)
api_router.include_router(
static_connections.router, prefix="/static-connections", tags=["static-connections"]
)
api_router.include_router(
clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"]
)
api_router.include_router(
attachment_point_names.router,
prefix="/attachment-point-names",
tags=["attachment-point-names"],
)
api_router.include_router(
attachment_points.router, prefix="/attachment-points", tags=["attachment-points"]
)
api_router.include_router(
imported_parts.router, prefix="/imported-parts", tags=["imported-parts"]
)
api_router.include_router(
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
)
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
api_router.include_router(
scope_import.router, prefix="/scope-import", tags=["scope-import"]
)
-53
View File
@@ -1,53 +0,0 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_import import ScopeImportResult
from app.services import scope_import as scope_import_service
router = APIRouter()
_XLSX_TYPES = {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/octet-stream",
"",
}
@router.post("", response_model=ScopeImportResult)
async def import_scopes(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
filename = file.filename or ""
if not filename.lower().endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Expected an .xlsx spreadsheet.",
)
data = await file.read()
try:
result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # openpyxl / malformed workbook
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not read spreadsheet: {exc}",
) from exc
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Scope import ({filename}): {result.created} created, "
f"{result.updated} updated, {result.skipped} skipped, "
f"{result.errors} errors"
),
)
return result
-108
View File
@@ -1,108 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import scope_model as scope_model_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_model import ScopeModelCreate, ScopeModelRead, ScopeModelUpdate
router = APIRouter()
@router.get("", response_model=list[ScopeModelRead])
async def list_scope_models(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
return await scope_model_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ScopeModelRead, status_code=status.HTTP_201_CREATED)
async def create_scope_model(
payload: ScopeModelCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.create(
db,
name=payload.name,
manufacturer_id=payload.manufacturer_id,
url=str(payload.url) if payload.url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_create("ScopeModel", sm.id, sm.name),
)
return sm
@router.get("/{scope_model_id}", response_model=ScopeModelRead)
async def get_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
return sm
@router.patch("/{scope_model_id}", response_model=ScopeModelRead)
async def update_scope_model(
scope_model_id: int,
payload: ScopeModelUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
sm = await scope_model_crud.update(db, sm, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_update("ScopeModel", sm.id, data),
)
return sm
@router.delete("/{scope_model_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:delete")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
name = sm.name
await scope_model_crud.delete(db, sm)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ScopeModel",
resource_id=scope_model_id,
description=audit_crud.describe_delete("ScopeModel", scope_model_id, name),
)
-107
View File
@@ -1,107 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import static_connection as static_connection_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.static_connection import (
StaticConnectionCreate,
StaticConnectionRead,
StaticConnectionUpdate,
)
router = APIRouter()
@router.get("", response_model=list[StaticConnectionRead])
async def list_static_connections(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
return await static_connection_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=StaticConnectionRead, status_code=status.HTTP_201_CREATED
)
async def create_static_connection(
payload: StaticConnectionCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.create(
db, od_mm=payload.od_mm, od_in=payload.od_in
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_create(
"StaticConnection", sc.id, f"{sc.od_mm} mm"
),
)
return sc
@router.get("/{static_connection_id}", response_model=StaticConnectionRead)
async def get_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
return sc
@router.patch("/{static_connection_id}", response_model=StaticConnectionRead)
async def update_static_connection(
static_connection_id: int,
payload: StaticConnectionUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
changes = payload.model_dump(exclude_unset=True)
sc = await static_connection_crud.update(db, sc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_update("StaticConnection", sc.id, changes),
)
return sc
@router.delete("/{static_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:delete")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
od_mm = sc.od_mm
await static_connection_crud.delete(db, sc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="StaticConnection",
resource_id=static_connection_id,
description=audit_crud.describe_delete(
"StaticConnection", static_connection_id, f"{od_mm} mm"
),
)
-107
View File
@@ -1,107 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.sub_part_type import (
SubPartTypeCreate,
SubPartTypeRead,
SubPartTypeUpdate,
)
router = APIRouter()
@router.get("", response_model=list[SubPartTypeRead])
async def list_sub_part_types(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
return await sub_part_type_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=SubPartTypeRead, status_code=status.HTTP_201_CREATED)
async def create_sub_part_type(
payload: SubPartTypeCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.create(
db, name=payload.name, part_type_id=payload.part_type_id
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_create("SubPartType", spt.id, spt.name),
)
return spt
@router.get("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def get_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
return spt
@router.patch("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def update_sub_part_type(
sub_part_type_id: int,
payload: SubPartTypeUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
changes = payload.model_dump(exclude_unset=True)
spt = await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_update("SubPartType", spt.id, changes),
)
return spt
@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:delete")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
name = spt.name
await sub_part_type_crud.delete(db, spt)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="SubPartType",
resource_id=sub_part_type_id,
description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name),
)
-111
View File
@@ -1,111 +0,0 @@
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),
)
+13 -42
View File
@@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import user as user_crud from app.crud import user as user_crud
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.models.user import User from app.models.user import User
@@ -30,13 +29,13 @@ async def list_users(
async def create_user( async def create_user(
payload: UserCreate, payload: UserCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("users:write")), _: User = Depends(require_permission("users:write")),
): ):
if await user_crud.get_by_email(db, payload.email): if await user_crud.get_by_email(db, payload.email):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered" status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
) )
created = await user_crud.create( return await user_crud.create(
db, db,
email=payload.email, email=payload.email,
password=payload.password, password=payload.password,
@@ -44,15 +43,6 @@ async def create_user(
is_superuser=payload.is_superuser, is_superuser=payload.is_superuser,
role_ids=payload.role_ids, role_ids=payload.role_ids,
) )
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="User",
resource_id=created.id,
description=audit_crud.describe_create("User", created.id, created.email),
)
return created
@router.get("/{user_id}", response_model=UserRead) @router.get("/{user_id}", response_model=UserRead)
@@ -61,12 +51,12 @@ async def get_user(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")), _: User = Depends(require_permission("users:read")),
): ):
target = await user_crud.get(db, user_id) user = await user_crud.get(db, user_id)
if target is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) )
return target return user
@router.patch("/{user_id}", response_model=UserRead) @router.patch("/{user_id}", response_model=UserRead)
@@ -74,44 +64,25 @@ async def update_user(
user_id: int, user_id: int,
payload: UserUpdate, payload: UserUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("users:write")), _: User = Depends(require_permission("users:write")),
): ):
target = await user_crud.get(db, user_id) user = await user_crud.get(db, user_id)
if target is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) )
changes = payload.model_dump(exclude_unset=True) return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
target = await user_crud.update(db, target, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="User",
resource_id=user_id,
description=audit_crud.describe_update("User", user_id, changes),
)
return target
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
user_id: int, user_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("users:delete")), _: User = Depends(require_permission("users:delete")),
): ):
target = await user_crud.get(db, user_id) user = await user_crud.get(db, user_id)
if target is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found" status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) )
email = target.email await user_crud.delete(db, user)
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),
)
+34 -13
View File
@@ -37,22 +37,43 @@ class Settings(BaseSettings):
cookie_name: str = "access_token" cookie_name: str = "access_token"
cookie_secure: bool = False cookie_secure: bool = False
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
# ``{username}`` substituted) and bound directly with their password.
ldap_enabled: bool = False
ldap_host: str = "localhost"
ldap_port: int = 389
ldap_use_ssl: bool = False
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
ldap_email_domain: str = "example.com"
# LDAP attribute read for the provisioned user's display name.
ldap_full_name_attr: str = "cn"
# CORS # CORS
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
# --- LDAP / Active Directory (optional) --------------------------------
# When ``ldap_enabled`` is true, login credentials are validated by binding
# to the directory. A local ``User`` row is provisioned on first successful
# login so RBAC, tokens, and the admin UI keep working. Local database auth
# remains available as a fallback (so the seeded superuser can still log in).
ldap_enabled: bool = False
# e.g. "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"
ldap_server_uri: str = ""
ldap_use_ssl: bool = False
ldap_start_tls: bool = False
# Verify the server's TLS certificate (ldaps/StartTLS). Disable only for dev.
ldap_tls_validate: bool = True
ldap_ca_certs_file: str = ""
# Two ways to locate/authenticate the user (direct bind takes precedence):
#
# 1. Direct bind — build the user's DN from the login name, then bind as it:
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
ldap_bind_dn_template: str = ""
#
# 2. Search + bind — bind with a service account (or anonymously), search for
# the user, then re-bind as the found DN to verify the password:
ldap_bind_dn: str = ""
ldap_bind_password: str = ""
ldap_user_search_base: str = ""
ldap_user_search_filter: str = "(uid={username})"
# Attributes read from the directory entry to populate the local user.
ldap_attr_email: str = "mail"
ldap_attr_full_name: str = "cn"
# Roles (by name) granted to a user provisioned from LDAP on first login.
ldap_default_roles: list[str] = ["user"]
# Bootstrap superuser (used by scripts/seed.py) # Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com" first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis" first_superuser_password: str = "changethis"
-14
View File
@@ -5,7 +5,6 @@ Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
@@ -22,19 +21,6 @@ class Base(DeclarativeBase):
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True) engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
if engine.sync_engine.dialect.name == "sqlite":
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record) -> None:
"""WAL mode lets readers/writers avoid blocking each other; busy_timeout
makes a connection wait for a lock instead of immediately raising
"database is locked" (the pysqlite/aiosqlite default is 0ms).
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
AsyncSessionLocal = async_sessionmaker( AsyncSessionLocal = async_sessionmaker(
engine, engine,
class_=AsyncSession, class_=AsyncSession,
+11
View File
@@ -1,6 +1,7 @@
"""Password hashing and JWT token helpers.""" """Password hashing and JWT token helpers."""
import hashlib import hashlib
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -27,6 +28,16 @@ def verify_password(password: str, hashed: str) -> bool:
return False return False
def make_unusable_password() -> str:
"""A sentinel hash that no password can match.
Used for externally-authenticated users (e.g. LDAP) who have no local
password. It is not a valid bcrypt hash, so ``verify_password`` always
returns ``False`` for it — local password auth can never succeed.
"""
return "!" + secrets.token_urlsafe(32)
# --- JWT --------------------------------------------------------------------- # --- JWT ---------------------------------------------------------------------
-76
View File
@@ -1,76 +0,0 @@
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()
-50
View File
@@ -1,50 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point_name import AttachmentPointName
async def get(db: AsyncSession, apn_id: int) -> AttachmentPointName | None:
return await db.get(AttachmentPointName, apn_id)
async def get_by_name(db: AsyncSession, name: str) -> AttachmentPointName | None:
result = await db.execute(
select(AttachmentPointName).where(AttachmentPointName.name == name)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPointName]:
result = await db.execute(
select(AttachmentPointName)
.order_by(AttachmentPointName.name)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> AttachmentPointName:
apn = AttachmentPointName(name=name)
db.add(apn)
await db.commit()
await db.refresh(apn)
return apn
async def update(
db: AsyncSession, apn: AttachmentPointName, data: dict
) -> AttachmentPointName:
for field, value in data.items():
setattr(apn, field, value)
await db.commit()
await db.refresh(apn)
return apn
async def delete(db: AsyncSession, apn: AttachmentPointName) -> None:
await db.delete(apn)
await db.commit()
-48
View File
@@ -1,48 +0,0 @@
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())
-203
View File
@@ -1,203 +0,0 @@
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()
-50
View File
@@ -1,50 +0,0 @@
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()
-98
View File
@@ -1,98 +0,0 @@
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()
-17
View File
@@ -1,17 +0,0 @@
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
-110
View File
@@ -1,110 +0,0 @@
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)
-34
View File
@@ -1,34 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part_config import ImportedPartConfig
_SINGLETON_ID = 1
async def get(db: AsyncSession) -> ImportedPartConfig | None:
return await db.get(ImportedPartConfig, _SINGLETON_ID)
async def upsert(
db: AsyncSession,
*,
odata_url: str | None,
username: str | None,
password: str | None,
) -> ImportedPartConfig:
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
if config is None:
config = ImportedPartConfig(
id=_SINGLETON_ID,
odata_url=odata_url,
username=username,
password=password,
)
db.add(config)
else:
config.odata_url = odata_url
config.username = username
config.password = password
await db.commit()
await db.refresh(config)
return config
-66
View File
@@ -1,66 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.iso_thread import IsoThread
async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
return await db.get(IsoThread, iso_thread_id)
async def get_by_values(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None,
) -> IsoThread | None:
depth_clause = (
IsoThread.depth_mm.is_(None)
if depth_mm is None
else IsoThread.depth_mm == depth_mm
)
result = await db.execute(
select(IsoThread).where(
IsoThread.diameter_mm == diameter_mm,
IsoThread.pitch_mm == pitch_mm,
depth_clause,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[IsoThread]:
result = await db.execute(
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None = None,
) -> IsoThread:
thread = IsoThread(diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=depth_mm)
db.add(thread)
await db.commit()
await db.refresh(thread)
return thread
async def update(db: AsyncSession, thread: IsoThread, data: dict) -> IsoThread:
for field, value in data.items():
setattr(thread, field, value)
await db.commit()
await db.refresh(thread)
return thread
async def delete(db: AsyncSession, thread: IsoThread) -> None:
await db.delete(thread)
await db.commit()
-51
View File
@@ -1,51 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.manufacturer import Manufacturer
async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None:
return await db.get(Manufacturer, manufacturer_id)
async def get_by_name(db: AsyncSession, name: str) -> Manufacturer | None:
result = await db.execute(select(Manufacturer).where(Manufacturer.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[Manufacturer]:
result = await db.execute(
select(Manufacturer).order_by(Manufacturer.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
url: str,
logo_url: str | None = None,
) -> Manufacturer:
m = Manufacturer(name=name, url=url, logo_url=logo_url)
db.add(m)
await db.commit()
await db.refresh(m)
return m
async def update(
db: AsyncSession, manufacturer: Manufacturer, data: dict
) -> Manufacturer:
for field, value in data.items():
setattr(manufacturer, field, value)
await db.commit()
await db.refresh(manufacturer)
return manufacturer
async def delete(db: AsyncSession, manufacturer: Manufacturer) -> None:
await db.delete(manufacturer)
await db.commit()
-43
View File
@@ -1,43 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.part_colour import PartColour
async def get(db: AsyncSession, part_colour_id: int) -> PartColour | None:
return await db.get(PartColour, part_colour_id)
async def get_by_name(db: AsyncSession, name: str) -> PartColour | None:
result = await db.execute(select(PartColour).where(PartColour.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[PartColour]:
result = await db.execute(
select(PartColour).order_by(PartColour.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> PartColour:
pc = PartColour(name=name)
db.add(pc)
await db.commit()
await db.refresh(pc)
return pc
async def update(db: AsyncSession, pc: PartColour, data: dict) -> PartColour:
for field, value in data.items():
setattr(pc, field, value)
await db.commit()
await db.refresh(pc)
return pc
async def delete(db: AsyncSession, pc: PartColour) -> None:
await db.delete(pc)
await db.commit()
-41
View File
@@ -1,41 +0,0 @@
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()
-28
View File
@@ -1,5 +1,4 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission from app.models.permission import Permission
@@ -15,23 +14,6 @@ async def get_by_name(db: AsyncSession, name: str) -> Role | None:
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_default(db: AsyncSession) -> Role | None:
"""The role auto-assigned to new users, or ``None`` if none is marked."""
result = await db.execute(select(Role).where(Role.is_default.is_(True)))
return result.scalars().first()
async def set_default(db: AsyncSession, role: Role) -> Role:
"""Make ``role`` the sole default, clearing the flag on every other role."""
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
await db.commit()
await db.refresh(role)
return role
async def list_roles(db: AsyncSession) -> list[Role]: async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id)) result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -68,16 +50,6 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else: else:
data.pop("permission_ids", None) data.pop("permission_ids", None)
# Setting a role default must clear the flag on all others.
make_default = data.pop("is_default", None)
if make_default:
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
elif make_default is False:
role.is_default = False
for field, value in data.items(): for field, value in data.items():
if value is not None: if value is not None:
setattr(role, field, value) setattr(role, field, value)
-56
View File
@@ -1,56 +0,0 @@
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()
-56
View File
@@ -1,56 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scope_model import ScopeModel
async def get(db: AsyncSession, scope_model_id: int) -> ScopeModel | None:
return await db.get(ScopeModel, scope_model_id)
async def get_by_name_and_manufacturer(
db: AsyncSession, name: str, manufacturer_id: int
) -> ScopeModel | None:
result = await db.execute(
select(ScopeModel).where(
ScopeModel.name == name,
ScopeModel.manufacturer_id == manufacturer_id,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[ScopeModel]:
result = await db.execute(
select(ScopeModel).order_by(ScopeModel.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
manufacturer_id: int,
url: str | None = None,
) -> ScopeModel:
sm = ScopeModel(name=name, manufacturer_id=manufacturer_id, url=url)
db.add(sm)
await db.commit()
await db.refresh(sm, attribute_names=["manufacturer"])
return sm
async def update(db: AsyncSession, scope_model: ScopeModel, data: dict) -> ScopeModel:
for field, value in data.items():
setattr(scope_model, field, value)
await db.commit()
await db.refresh(scope_model, attribute_names=["manufacturer"])
return scope_model
async def delete(db: AsyncSession, scope_model: ScopeModel) -> None:
await db.delete(scope_model)
await db.commit()
-55
View File
@@ -1,55 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.static_connection import StaticConnection
async def get(db: AsyncSession, static_connection_id: int) -> StaticConnection | None:
return await db.get(StaticConnection, static_connection_id)
async def get_by_od_mm(db: AsyncSession, od_mm: float) -> StaticConnection | None:
result = await db.execute(
select(StaticConnection).where(StaticConnection.od_mm == od_mm)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[StaticConnection]:
result = await db.execute(
select(StaticConnection)
.order_by(StaticConnection.od_mm)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
od_mm: float,
od_in: float,
) -> StaticConnection:
sc = StaticConnection(od_mm=od_mm, od_in=od_in)
db.add(sc)
await db.commit()
await db.refresh(sc)
return sc
async def update(
db: AsyncSession, sc: StaticConnection, data: dict
) -> StaticConnection:
for field, value in data.items():
setattr(sc, field, value)
await db.commit()
await db.refresh(sc)
return sc
async def delete(db: AsyncSession, sc: StaticConnection) -> None:
await db.delete(sc)
await db.commit()
-51
View File
@@ -1,51 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sub_part_type import SubPartType
async def get(db: AsyncSession, sub_part_type_id: int) -> SubPartType | None:
return await db.get(SubPartType, sub_part_type_id)
async def get_by_name_and_part_type(
db: AsyncSession, name: str, part_type_id: int
) -> SubPartType | None:
result = await db.execute(
select(SubPartType).where(
SubPartType.name == name, SubPartType.part_type_id == part_type_id
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[SubPartType]:
result = await db.execute(
select(SubPartType).order_by(SubPartType.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str, part_type_id: int) -> SubPartType:
spt = SubPartType(name=name, part_type_id=part_type_id)
db.add(spt)
await db.commit()
await db.refresh(spt, attribute_names=["part_type"])
return spt
async def update(
db: AsyncSession, sub_part_type: SubPartType, data: dict
) -> SubPartType:
for field, value in data.items():
setattr(sub_part_type, field, value)
await db.commit()
await db.refresh(sub_part_type, attribute_names=["part_type"])
return sub_part_type
async def delete(db: AsyncSession, sub_part_type: SubPartType) -> None:
await db.delete(sub_part_type)
await db.commit()
-74
View File
@@ -1,74 +0,0 @@
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()
+46 -39
View File
@@ -1,9 +1,9 @@
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password from app.core.security import hash_password, make_unusable_password, verify_password
from app.models.role import Role from app.models.role import Role
from app.models.user import AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User from app.models.user import User
async def get(db: AsyncSession, user_id: int) -> User | None: async def get(db: AsyncSession, user_id: int) -> User | None:
@@ -15,11 +15,6 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]: async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit)) result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -37,24 +32,29 @@ async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
return list(result.scalars().all()) return list(result.scalars().all())
async def _resolve_roles_by_name(db: AsyncSession, names: list[str]) -> list[Role]:
if not names:
return []
result = await db.execute(select(Role).where(Role.name.in_(names)))
return list(result.scalars().all())
async def create( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
email: str, email: str,
password: str | None = None, password: str,
full_name: str | None = None, full_name: str | None = None,
is_superuser: bool = False, is_superuser: bool = False,
role_ids: list[int] | None = None, role_ids: list[int] | None = None,
username: str | None = None, auth_provider: str = "local",
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User: ) -> User:
user = User( user = User(
email=email, email=email,
username=username, hashed_password=hash_password(password),
hashed_password=hash_password(password) if password else None,
auth_source=auth_source,
full_name=full_name, full_name=full_name,
is_superuser=is_superuser, is_superuser=is_superuser,
auth_provider=auth_provider,
roles=await _resolve_roles(db, role_ids or []), roles=await _resolve_roles(db, role_ids or []),
) )
db.add(user) db.add(user)
@@ -63,35 +63,45 @@ async def create(
return user return user
async def get_or_create_ldap_user( async def upsert_ldap_user(
db: AsyncSession, db: AsyncSession,
*, *,
username: str,
email: str, email: str,
full_name: str | None, full_name: str | None = None,
role_ids: list[int] | None = None, default_role_names: list[str] | None = None,
) -> User: ) -> User:
"""Find an LDAP user by username, provisioning a local profile on first login. """Provision (or refresh) the local row for an LDAP-authenticated user.
The profile is keyed on the LDAP username; email is synthesized by the On first login the user is created with an unusable local password (so
caller. Newly provisioned users get ``role_ids`` (the default role). 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.
""" """
user = await get_by_username(db, username) user = await get_by_email(db, email)
if user is not None: if user is None:
# Keep the display name in sync with LDAP on subsequent logins. user = User(
if full_name and user.full_name != full_name: email=email,
user.full_name = full_name hashed_password=make_unusable_password(),
await db.commit() full_name=full_name,
await db.refresh(user) auth_provider="ldap",
roles=await _resolve_roles_by_name(db, default_role_names or []),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user return user
return await create(
db, changed = False
email=email, if user.auth_provider != "ldap":
username=username, user.auth_provider = "ldap"
full_name=full_name, changed = True
auth_source=AUTH_SOURCE_LDAP, if full_name and user.full_name != full_name:
role_ids=role_ids, user.full_name = full_name
) changed = True
if changed:
await db.commit()
await db.refresh(user)
return user
async def update(db: AsyncSession, user: User, data: dict) -> User: async def update(db: AsyncSession, user: User, data: dict) -> User:
@@ -121,9 +131,6 @@ async def delete(db: AsyncSession, user: User) -> None:
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None: async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email) user = await get_by_email(db, email)
# LDAP users have no local password and must sign in via LDAP. if not user or not verify_password(password, user.hashed_password):
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None return None
return user return user
+1 -42
View File
@@ -1,57 +1,16 @@
"""Import all models so they register with ``Base.metadata``.""" """Import all models so they register with ``Base.metadata``."""
from app.models.associations import ( from app.models.associations import role_permissions, user_roles
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.permission import Permission
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken
from app.models.role import Role from app.models.role import Role
from app.models.unc_thread import UncThread
from app.models.user import User from app.models.user import User
__all__ = [ __all__ = [
"AttachmentPoint",
"AttachmentPointName",
"AuditLog",
"AugmentedPart",
"ClampConnection",
"Fit",
"FitLabel",
"FitNote",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Manufacturer",
"PartColour",
"PartType",
"Permission", "Permission",
"ScopeInfo",
"ScopeModel",
"StaticConnection",
"SubPartType",
"RefreshToken", "RefreshToken",
"Role", "Role",
"UncThread",
"User", "User",
"augmented_part_imported_parts",
"role_permissions", "role_permissions",
"user_roles", "user_roles",
] ]
+2 -18
View File
@@ -1,6 +1,6 @@
"""Many-to-many association tables.""" """Many-to-many association tables for RBAC."""
from sqlalchemy import Column, ForeignKey, String, Table from sqlalchemy import Column, ForeignKey, Table
from app.core.database import Base from app.core.database import Base
@@ -21,19 +21,3 @@ role_permissions = Table(
primary_key=True, primary_key=True,
), ),
) )
augmented_part_imported_parts = Table(
"augmented_part_imported_parts",
Base.metadata,
Column(
"augmented_part_id",
ForeignKey("augmented_parts.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"imported_part_id",
String,
ForeignKey("imported_parts.InventoryID", ondelete="CASCADE"),
primary_key=True,
),
)
-46
View File
@@ -1,46 +0,0 @@
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")
-12
View File
@@ -1,12 +0,0 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AttachmentPointName(Base):
__tablename__ = "attachment_point_names"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
-23
View File
@@ -1,23 +0,0 @@
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)
-47
View File
@@ -1,47 +0,0 @@
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")
-14
View File
@@ -1,14 +0,0 @@
from sqlalchemy import Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ClampConnection(Base):
__tablename__ = "clamp_connections"
id: Mapped[int] = mapped_column(primary_key=True)
min_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
min_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
-89
View File
@@ -1,89 +0,0 @@
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")
-21
View File
@@ -1,21 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPart(Base):
__tablename__ = "imported_parts"
InventoryID: Mapped[str] = mapped_column(String, primary_key=True)
Description: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXColour: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductMaxThreadDepthmm: Mapped[str | None] = mapped_column(
nullable=True, default=None
)
ProductMaxThreadDepthin: Mapped[str | None] = mapped_column(
nullable=True, default=None
)
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
-15
View File
@@ -1,15 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPartConfig(Base):
"""Singleton (id=1) storing the OData source URL and credentials."""
__tablename__ = "imported_part_config"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
odata_url: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
username: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
password: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
-13
View File
@@ -1,13 +0,0 @@
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)
-12
View File
@@ -1,12 +0,0 @@
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Manufacturer(Base):
__tablename__ = "manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=False)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
-12
View File
@@ -1,12 +0,0 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PartColour(Base):
__tablename__ = "part_colours"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
-10
View File
@@ -1,10 +0,0 @@
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)
+1 -4
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, String from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -11,9 +11,6 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None) description: Mapped[str | None] = mapped_column(String(255), default=None)
# The role auto-assigned to newly provisioned/registered users. At most one
# role is the default; see ``crud.role.set_default``.
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
users: Mapped[list["User"]] = relationship( # noqa: F821 users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles, secondary=user_roles,
-34
View File
@@ -1,34 +0,0 @@
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
)
-18
View File
@@ -1,18 +0,0 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.manufacturer import Manufacturer
class ScopeModel(Base):
__tablename__ = "scope_models"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
manufacturer_id: Mapped[int] = mapped_column(
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
)
url: Mapped[str | None] = mapped_column(nullable=True, default=None)
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
-13
View File
@@ -1,13 +0,0 @@
from sqlalchemy import Float, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class StaticConnection(Base):
__tablename__ = "static_connections"
__table_args__ = (UniqueConstraint("od_mm"),)
id: Mapped[int] = mapped_column(primary_key=True)
od_mm: Mapped[float] = mapped_column(Float, nullable=False)
od_in: Mapped[float] = mapped_column(Float, nullable=False)
-18
View File
@@ -1,18 +0,0 @@
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.part_type import PartType
class SubPartType(Base):
__tablename__ = "sub_part_types"
__table_args__ = (UniqueConstraint("name", "part_type_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
part_type_id: Mapped[int] = mapped_column(
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
)
part_type: Mapped[PartType] = relationship(lazy="selectin")
-24
View File
@@ -1,24 +0,0 @@
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)
+5 -11
View File
@@ -6,26 +6,20 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
from app.models.associations import user_roles from app.models.associations import user_roles
# Where a user's credentials are verified.
AUTH_SOURCE_LOCAL = "local"
AUTH_SOURCE_LDAP = "ldap"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
# Set for users provisioned from LDAP; how they sign in. Null for local users. hashed_password: Mapped[str] = mapped_column(String(255))
username: Mapped[str | None] = mapped_column(
String(255), unique=True, index=True, default=None
)
# Null for LDAP users, who have no local password (verified against LDAP).
hashed_password: Mapped[str | None] = mapped_column(String(255), default=None)
auth_source: Mapped[str] = mapped_column(String(16), default=AUTH_SOURCE_LOCAL)
full_name: Mapped[str | None] = mapped_column(String(255), default=None) full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
# Where this account authenticates: "local" (password) or "ldap".
auth_provider: Mapped[str] = mapped_column(
String(20), default="local", server_default="local"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
-39
View File
@@ -1,39 +0,0 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.attachment_point_name import AttachmentPointNameRead
from app.schemas.clamp_connection import ClampConnectionRead
from app.schemas.iso_thread import IsoThreadRead
from app.schemas.static_connection import StaticConnectionRead
from app.schemas.unc_thread import UncThreadRead
class AttachmentPointBase(BaseModel):
augmented_part_id: int
attachment_point_name_id: int
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointCreate(AttachmentPointBase):
pass
class AttachmentPointUpdate(BaseModel):
augmented_part_id: int | None = None
attachment_point_name_id: int | None = None
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointRead(AttachmentPointBase):
model_config = ConfigDict(from_attributes=True)
id: int
attachment_point_name: AttachmentPointNameRead
iso_thread: IsoThreadRead | None = None
unc_thread: UncThreadRead | None = None
clamp_connection: ClampConnectionRead | None = None
static_connection: StaticConnectionRead | None = None
-18
View File
@@ -1,18 +0,0 @@
from pydantic import BaseModel, ConfigDict
class AttachmentPointNameBase(BaseModel):
name: str
class AttachmentPointNameCreate(AttachmentPointNameBase):
pass
class AttachmentPointNameUpdate(BaseModel):
name: str | None = None
class AttachmentPointNameRead(AttachmentPointNameBase):
model_config = ConfigDict(from_attributes=True)
id: int
-16
View File
@@ -1,16 +0,0 @@
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
-48
View File
@@ -1,48 +0,0 @@
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
-21
View File
@@ -1,21 +0,0 @@
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
-60
View File
@@ -1,60 +0,0 @@
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
-34
View File
@@ -1,34 +0,0 @@
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
-23
View File
@@ -1,23 +0,0 @@
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
-23
View File
@@ -1,23 +0,0 @@
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
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more