Compare commits

...
37 Commits
Author SHA1 Message Date
Ian Roddis b5529e0dc5 Exposing data path to support sqlite volume maps 2026-07-20 11:00:00 -04:00
Ian Roddis b6dc6dd6b8 Adding entrypoint to seed database 2026-07-17 17:03:35 -03:00
Ian Roddis aca15db9bf Fixing string padding in import 2026-07-17 15:27:15 -03:00
Ian Roddis a0bd7f2c27 Updating makefile 2026-07-17 09:24:47 -03:00
Ian Roddis 0150c19b75 Making login screen a bit clearer 2026-07-16 22:10:36 -03:00
Ian Roddis 1180694738 Adding cur env 2026-07-16 22:05:13 -03:00
Ian Roddis 6d20db75c5 adding logging for ldap exceptions 2026-07-16 22:04:48 -03:00
Ian Roddis 69cbfa2d3f Adding scope import 2026-07-16 20:15:35 -03:00
Ian Roddis cda15df2ed Adding LDAP login 2026-07-16 17:05:30 -03:00
Ian Roddis ab39191af5 Adding fits 2026-07-14 15:24:05 -03:00
Ian Roddis 8185f1f8b3 Reformatting everything with black 2026-07-14 14:36:49 -03:00
Ian Roddis 7bd07f084a Adjusting linkages 2026-07-13 16:14:12 -03:00
Ian Roddis 87ba8cd169 Adding automatic attachment point addition 2026-07-03 09:57:10 -03:00
Ian Roddis c5d1186b95 Adding light theme 2026-07-02 15:26:45 -03:00
Ian Roddis 4785fd5f87 Adjusting styles 2026-07-02 15:19:48 -03:00
Ian Roddis e923250177 Adding pagination 2026-07-02 14:48:21 -03:00
Ian Roddis a50fccd56d Adding filtering to tables 2026-07-02 14:36:43 -03:00
Ian Roddis d67b4ceb65 Adding ability to add attachment points when editing augmented part 2026-07-02 14:13:09 -03:00
Ian Roddis ceae27a12e Adding Attachment Points 2026-07-02 14:06:18 -03:00
Ian Roddis 2767fb1c8e Adding AttachmentPointNames 2026-07-02 13:57:03 -03:00
Ian Roddis ae50ec7049 Fixing time between flyouts 2026-07-02 13:50:10 -03:00
Ian Roddis 7fcf5b3f75 Relayout of nav 2026-07-02 13:38:43 -03:00
Ian Roddis 59184124e7 Add clamp connection 2026-07-02 13:35:58 -03:00
Ian Roddis 0168b023af Moving post-import reconciliation to a separate page 2026-07-02 13:22:28 -03:00
Ian Roddis 87019ff2af Adding post-import adjustments 2026-07-02 13:12:09 -03:00
Ian Roddis c320595423 Adding sync with imported parts 2026-07-02 12:55:13 -03:00
Ian Roddis 00fb43c8ed Adding augmented parts 2026-07-02 12:38:59 -03:00
Ian Roddis 2e7002b73b adding part colour 2026-07-02 12:34:33 -03:00
Ian Roddis 422e217180 adding static connection 2026-07-02 12:18:06 -03:00
Ian Roddis eba7896893 Doing a relayout of permissions to make it a bit easier to navigate 2026-06-25 16:06:36 -03:00
Ian Roddis 1549971a13 Making containers reasonable 2026-06-25 15:54:51 -03:00
Ian Roddis 291416544e Adding OData Import 2026-06-25 15:52:21 -03:00
Ian Roddis c8696e67b5 Adding sub-parts 2026-06-25 15:14:37 -03:00
Ian Roddis 45bc5bf8f6 Adding audit log 2026-06-25 11:25:26 -03:00
Ian Roddis fe6f9cca05 Adding Part Types 2026-06-25 10:56:46 -03:00
Ian Roddis b7ad517174 Adding Manufacturers and ScopeModels 2026-06-25 10:46:39 -03:00
Ian Roddis 9111117404 Adding mise 2026-06-25 10:07:02 -03:00
181 changed files with 11710 additions and 195 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(uv run *)",
"Bash(uv sync *)",
"Bash(git stash *)",
"Bash(python3 *)",
"Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")",
"Bash(PYTHONPATH=. uv run *)",
"Bash(python *)",
"Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)",
"Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')",
"Bash(docker build *)"
]
}
}
+49
View File
@@ -0,0 +1,49 @@
# Copy to .env and adjust. Every value here can also be supplied as a real
# environment variable (which takes precedence over the .env file).
# --- General ---
APP_NAME="Armament FitGuide"
DEBUG=true
# --- Security ---
# Generate a strong value, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
SECRET_KEY="change-me-please-use-a-long-random-string"
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# --- Database ---
# SQLite (zero-setup default):
DATABASE_URL="sqlite+aiosqlite:///./app.db"
# Postgres example:
# DATABASE_URL="postgresql+asyncpg://app:app@localhost:5432/app"
# Create tables on startup (handy for dev/SQLite; use Alembic in production).
AUTO_CREATE_TABLES=true
# --- Web / cookies ---
COOKIE_NAME=access_token
# Set true when serving over HTTPS.
COOKIE_SECURE=false
# --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
# username + password. On first login a local profile is provisioned using the
# username and the default role (mark one in the Roles admin UI).
LDAP_ENABLED=true
LDAP_HOST=192.168.110.10
LDAP_PORT=636
LDAP_USE_SSL=true
# "{username}" is substituted with the submitted username to form the bind DN.
LDAP_BIND_DN_TEMPLATE="CN={username},CN=Users,DC=tenarmtan,DC=com"
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=armament.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) ---
CORS_ORIGINS=["*"]
# --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@armament.com
FIRST_SUPERUSER_PASSWORD=changethis
+15
View File
@@ -26,6 +26,21 @@ COOKIE_NAME=access_token
# Set true when serving over HTTPS.
COOKIE_SECURE=false
# --- LDAP (optional; direct-bind) ---
# When enabled, the login page and /api/auth/ldap-login accept a directory
# username + password. On first login a local profile is provisioned using the
# username and the default role (mark one in the Roles admin UI).
LDAP_ENABLED=false
LDAP_HOST=localhost
LDAP_PORT=389
LDAP_USE_SSL=false
# "{username}" is substituted with the submitted username to form the bind DN.
LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Provisioned users get the email "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=example.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) ---
CORS_ORIGINS=["*"]
+5 -2
View File
@@ -18,8 +18,11 @@ RUN uv sync --no-dev
# Copy the application.
COPY . .
RUN chmod +x entrypoint.sh
# If sqlite3 is used
VOLUME /data
EXPOSE 8000
# Default: run the API server. docker-compose overrides this to also seed.
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Seeds perms/roles/superuser (idempotent, no-op if already present), then runs the API server.
ENTRYPOINT ["./entrypoint.sh"]
+20
View File
@@ -1,7 +1,11 @@
VERSION := $(shell git describe --tags --always)
IMAGE_NAME := fitguide
sync:
uv sync --extra dev
seed:
uv run alembic upgrade head
uv run python -m scripts.seed
dev:
@@ -17,3 +21,19 @@ migrate:
fmt:
uv run black .
clean:
rm app.db
uv run alembic upgrade head
uv run python -m scripts.seed
docker:
mkdir -p artifacts
docker build -t $(IMAGE_NAME):$(VERSION) .
docker-push: docker
docker tag $(IMAGE_NAME):$(VERSION) $(IMAGE_NAME):latest
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker tag $(IMAGE_NAME):$(VERSION) git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):$(VERSION)
docker push git.kinesin.ca/kinesin/$(IMAGE_NAME):latest
@@ -0,0 +1,29 @@
"""add part_colours
Revision ID: 0c3d1a5cff1f
Revises: 5b6a7817b408
Create Date: 2026-07-02 12:22:24.106795
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0c3d1a5cff1f"
down_revision: Union[str, None] = "5b6a7817b408"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,72 @@
"""add_fits_tables
Revision ID: 1b99be8a9b1e
Revises: adf4a5b9493e
Create Date: 2026-07-14 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "1b99be8a9b1e"
down_revision: Union[str, None] = "adf4a5b9493e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"fits",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("attachment_point_a_id", sa.Integer(), nullable=False),
sa.Column("attachment_point_b_id", sa.Integer(), nullable=False),
sa.Column("augmented_part_a_id", sa.Integer(), nullable=False),
sa.Column("augmented_part_b_id", sa.Integer(), nullable=False),
sa.Column(
"label",
sa.Enum("possible", "exact", "none", name="fitlabel"),
nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.ForeignKeyConstraint(
["attachment_point_a_id"], ["attachment_points.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["attachment_point_b_id"], ["attachment_points.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["augmented_part_a_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["augmented_part_b_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("attachment_point_a_id", "attachment_point_b_id"),
)
op.create_table(
"fit_notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("fit_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.ForeignKeyConstraint(["fit_id"], ["fits.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("fit_notes")
op.drop_table("fits")
@@ -0,0 +1,29 @@
"""add static_connections
Revision ID: 5b6a7817b408
Revises: 7be5ef599d3f
Create Date: 2026-07-02 11:57:13.095417
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "5b6a7817b408"
down_revision: Union[str, None] = "7be5ef599d3f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add attachment_point_names
Revision ID: 70e48bda8829
Revises: c7882cedc9ae
Create Date: 2026-07-02 13:54:58.335394
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "70e48bda8829"
down_revision: Union[str, None] = "c7882cedc9ae"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add_imported_parts_tables
Revision ID: 7be5ef599d3f
Revises: b60d0aa3ceda
Create Date: 2026-06-25 15:29:20.685050
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "7be5ef599d3f"
down_revision: Union[str, None] = "b60d0aa3ceda"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add augmented_parts
Revision ID: 8bc411b5814e
Revises: 0c3d1a5cff1f
Create Date: 2026-07-02 12:37:59.457033
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8bc411b5814e"
down_revision: Union[str, None] = "0c3d1a5cff1f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,52 @@
"""add_manufacturers_table
Revision ID: a192abbf4091
Revises: 627d4460a94f
Create Date: 2026-06-25 10:32:11.398529
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a192abbf4091"
down_revision: Union[str, None] = "627d4460a94f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"manufacturers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("url", sa.String(), nullable=False),
sa.Column("logo_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column(
"series",
existing_type=sa.VARCHAR(length=8),
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
existing_nullable=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
batch_op.alter_column(
"series",
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
type_=sa.VARCHAR(length=8),
existing_nullable=False,
)
op.drop_table("manufacturers")
# ### end Alembic commands ###
@@ -0,0 +1,39 @@
"""add_scope_models_table
Revision ID: a52e7ca7a925
Revises: a192abbf4091
Create Date: 2026-06-25 10:35:40.227253
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a52e7ca7a925"
down_revision: Union[str, None] = "a192abbf4091"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"scope_models",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("manufacturer_id", sa.Integer(), nullable=False),
sa.Column("url", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["manufacturer_id"], ["manufacturers.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("scope_models")
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add attachment_points
Revision ID: adf4a5b9493e
Revises: 70e48bda8829
Create Date: 2026-07-02 14:03:23.561253
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "adf4a5b9493e"
down_revision: Union[str, None] = "70e48bda8829"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add_sub_part_types_table
Revision ID: b60d0aa3ceda
Revises: fd5379986da4
Create Date: 2026-06-25 11:29:20.420056
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b60d0aa3ceda"
down_revision: Union[str, None] = "fd5379986da4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add augmented_part_imported_parts
Revision ID: bfbce324cd5f
Revises: 8bc411b5814e
Create Date: 2026-07-02 12:46:27.493942
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "bfbce324cd5f"
down_revision: Union[str, None] = "8bc411b5814e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add clamp_connections
Revision ID: c7882cedc9ae
Revises: bfbce324cd5f
Create Date: 2026-07-02 13:31:57.064381
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c7882cedc9ae"
down_revision: Union[str, None] = "bfbce324cd5f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,54 @@
"""add LDAP user fields and default role flag
Revision ID: c9f1a2b3d4e5
Revises: 1b99be8a9b1e
Create Date: 2026-07-16 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c9f1a2b3d4e5"
down_revision: str | None = "1b99be8a9b1e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Batch mode so SQLite can alter the nullability of hashed_password.
with op.batch_alter_table("users") as batch:
batch.add_column(sa.Column("username", sa.String(length=255), nullable=True))
batch.add_column(
sa.Column(
"auth_source",
sa.String(length=16),
nullable=False,
server_default="local",
)
)
batch.alter_column(
"hashed_password", existing_type=sa.String(length=255), nullable=True
)
batch.create_index("ix_users_username", ["username"], unique=True)
op.add_column(
"roles",
sa.Column(
"is_default", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
def downgrade() -> None:
op.drop_column("roles", "is_default")
with op.batch_alter_table("users") as batch:
batch.drop_index("ix_users_username")
batch.alter_column(
"hashed_password", existing_type=sa.String(length=255), nullable=False
)
batch.drop_column("auth_source")
batch.drop_column("username")
@@ -0,0 +1,38 @@
"""add_scope_infos_table
Revision ID: d1e2f3a4b5c6
Revises: c9f1a2b3d4e5
Create Date: 2026-07-16 20:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "d1e2f3a4b5c6"
down_revision: Union[str, None] = "c9f1a2b3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"scope_infos",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("augmented_part_id", sa.Integer(), nullable=False),
sa.Column("magnification_min", sa.Float(), nullable=True),
sa.Column("magnification_max", sa.Float(), nullable=True),
sa.Column("model_suffix", sa.String(), nullable=True),
sa.Column("objective_thread_pitch", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("augmented_part_id"),
)
def downgrade() -> None:
op.drop_table("scope_infos")
@@ -0,0 +1,35 @@
"""add_part_types_table
Revision ID: e23aa8dd2536
Revises: a52e7ca7a925
Create Date: 2026-06-25 10:49:56.463466
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e23aa8dd2536"
down_revision: Union[str, None] = "a52e7ca7a925"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"part_types",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("part_types")
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add_audit_logs_table
Revision ID: fd5379986da4
Revises: e23aa8dd2536
Create Date: 2026-06-25 11:06:13.028067
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "fd5379986da4"
down_revision: Union[str, None] = "e23aa8dd2536"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point_name as apn_crud
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.attachment_point_name import (
AttachmentPointNameCreate,
AttachmentPointNameRead,
AttachmentPointNameUpdate,
)
router = APIRouter()
@router.get("", response_model=list[AttachmentPointNameRead])
async def list_attachment_point_names(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_point_names:read")),
):
return await apn_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=AttachmentPointNameRead, status_code=status.HTTP_201_CREATED
)
async def create_attachment_point_name(
payload: AttachmentPointNameCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")),
):
apn = await apn_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AttachmentPointName",
resource_id=apn.id,
description=audit_crud.describe_create("AttachmentPointName", apn.id, apn.name),
)
return apn
@router.get("/{apn_id}", response_model=AttachmentPointNameRead)
async def get_attachment_point_name(
apn_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_point_names:read")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
return apn
@router.patch("/{apn_id}", response_model=AttachmentPointNameRead)
async def update_attachment_point_name(
apn_id: int,
payload: AttachmentPointNameUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
changes = payload.model_dump(exclude_unset=True)
apn = await apn_crud.update(db, apn, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AttachmentPointName",
resource_id=apn.id,
description=audit_crud.describe_update("AttachmentPointName", apn.id, changes),
)
return apn
@router.delete("/{apn_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment_point_name(
apn_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:delete")),
):
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
name = apn.name
await apn_crud.delete(db, apn)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AttachmentPointName",
resource_id=apn_id,
description=audit_crud.describe_delete("AttachmentPointName", apn_id, name),
)
+131
View File
@@ -0,0 +1,131 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.attachment_point import (
AttachmentPointCreate,
AttachmentPointRead,
AttachmentPointUpdate,
)
router = APIRouter()
def _connection_label(ap) -> str:
if ap.iso_thread:
return f"M{ap.iso_thread.diameter_mm}×{ap.iso_thread.pitch_mm}"
if ap.unc_thread:
return f"{ap.unc_thread.diameter_fraction}-{ap.unc_thread.tpi} {ap.unc_thread.series}"
if ap.clamp_connection:
cc = ap.clamp_connection
if cc.min_mm is not None:
return f"{cc.min_mm}–{cc.max_mm} mm"
return f"{cc.min_in}–{cc.max_in} in"
if ap.static_connection:
return f"{ap.static_connection.od_mm} mm OD"
return "—"
@router.get("", response_model=list[AttachmentPointRead])
async def list_attachment_points(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_points:read")),
):
return await attachment_point_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=AttachmentPointRead, status_code=status.HTTP_201_CREATED
)
async def create_attachment_point(
payload: AttachmentPointCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:write")),
):
conn_ids = [
payload.iso_thread_id,
payload.unc_thread_id,
payload.clamp_connection_id,
payload.static_connection_id,
]
if sum(v is not None for v in conn_ids) != 1:
raise HTTPException(
status_code=400,
detail="Exactly one connection (iso_thread, unc_thread, clamp_connection, or static_connection) must be provided.",
)
ap = await attachment_point_crud.create(db, **payload.model_dump())
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AttachmentPoint",
resource_id=ap.id,
description=audit_crud.describe_create(
"AttachmentPoint", ap.id, _connection_label(ap)
),
)
return ap
@router.get("/{attachment_point_id}", response_model=AttachmentPointRead)
async def get_attachment_point(
attachment_point_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("attachment_points:read")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
return ap
@router.patch("/{attachment_point_id}", response_model=AttachmentPointRead)
async def update_attachment_point(
attachment_point_id: int,
payload: AttachmentPointUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:write")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
changes = payload.model_dump(exclude_unset=True)
ap = await attachment_point_crud.update(db, ap, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AttachmentPoint",
resource_id=ap.id,
description=audit_crud.describe_update("AttachmentPoint", ap.id, changes),
)
return ap
@router.delete("/{attachment_point_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment_point(
attachment_point_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_points:delete")),
):
ap = await attachment_point_crud.get(db, attachment_point_id)
if ap is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
label = _connection_label(ap)
await attachment_point_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AttachmentPoint",
resource_id=attachment_point_id,
description=audit_crud.describe_delete(
"AttachmentPoint", attachment_point_id, label
),
)
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.audit_log import AuditLogRead
router = APIRouter()
@router.get("", response_model=list[AuditLogRead])
async def list_audit_log(
limit: int = 200,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("audit_log:read")),
):
return await audit_crud.list_recent(db, limit=limit)
+134
View File
@@ -0,0 +1,134 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.augmented_part import (
AugmentedPartCreate,
AugmentedPartRead,
AugmentedPartSyncResult,
AugmentedPartUpdate,
)
from app.services import augmented_part_sync
router = APIRouter()
@router.get("", response_model=list[AugmentedPartRead])
async def list_augmented_parts(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
return await augmented_part_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
async def create_augmented_part(
payload: AugmentedPartCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
ap = await augmented_part_crud.create(
db,
sku=payload.sku,
description=payload.description,
colour_id=payload.colour_id,
manufacturer_id=payload.manufacturer_id,
scope_model_id=payload.scope_model_id,
part_type_id=payload.part_type_id,
sub_part_type_id=payload.sub_part_type_id,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="AugmentedPart",
resource_id=ap.id,
description=audit_crud.describe_create(
"AugmentedPart", ap.id, ap.sku or ap.description
),
)
return ap
@router.post("/sync", response_model=AugmentedPartSyncResult)
async def sync_augmented_parts(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
result = await augmented_part_sync.sync(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.deleted} deleted, "
f"{result.skipped} skipped (no type match)"
),
)
return result
@router.get("/{augmented_part_id}", response_model=AugmentedPartRead)
async def get_augmented_part(
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
return ap
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
async def update_augmented_part(
augmented_part_id: int,
payload: AugmentedPartUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
changes = payload.model_dump(exclude_unset=True)
ap = await augmented_part_crud.update(db, ap, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=ap.id,
description=audit_crud.describe_update("AugmentedPart", ap.id, changes),
)
return ap
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_augmented_part(
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:delete")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
)
+32 -3
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import token as token_crud
@@ -10,27 +11,34 @@ from app.deps import get_current_user
from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead
from app.services import ldap as ldap_service
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user"
async def _default_role_ids(db: AsyncSession) -> list[int]:
role = await role_crud.get_default(db)
if role is None:
role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
return [role.id] if role else []
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
role_ids=role_ids,
role_ids=await _default_role_ids(db),
)
return user
@@ -52,6 +60,27 @@ async def login(
return Token(access_token=access, refresh_token=refresh)
@router.post("/ldap-login", response_model=Token)
async def ldap_login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
"""Authenticate against LDAP, provisioning the local profile on first login."""
if not settings.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="LDAP login is not enabled"
)
user = await ldap_service.login(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try:
+121
View File
@@ -0,0 +1,121 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.clamp_connection import (
ClampConnectionCreate,
ClampConnectionRead,
ClampConnectionUpdate,
)
router = APIRouter()
@router.get("", response_model=list[ClampConnectionRead])
async def list_clamp_connections(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("clamp_connections:read")),
):
return await clamp_connection_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=ClampConnectionRead, status_code=status.HTTP_201_CREATED
)
async def create_clamp_connection(
payload: ClampConnectionCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:write")),
):
cc = await clamp_connection_crud.create(
db,
min_mm=payload.min_mm,
max_mm=payload.max_mm,
min_in=payload.min_in,
max_in=payload.max_in,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="ClampConnection",
resource_id=cc.id,
description=audit_crud.describe_create(
"ClampConnection",
cc.id,
(
f"{cc.min_mm}–{cc.max_mm} mm"
if cc.min_mm is not None
else f"{cc.min_in}–{cc.max_in} in"
),
),
)
return cc
@router.get("/{clamp_connection_id}", response_model=ClampConnectionRead)
async def get_clamp_connection(
clamp_connection_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("clamp_connections:read")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
return cc
@router.patch("/{clamp_connection_id}", response_model=ClampConnectionRead)
async def update_clamp_connection(
clamp_connection_id: int,
payload: ClampConnectionUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:write")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
changes = payload.model_dump(exclude_unset=True)
cc = await clamp_connection_crud.update(db, cc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ClampConnection",
resource_id=cc.id,
description=audit_crud.describe_update("ClampConnection", cc.id, changes),
)
return cc
@router.delete("/{clamp_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_clamp_connection(
clamp_connection_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("clamp_connections:delete")),
):
cc = await clamp_connection_crud.get(db, clamp_connection_id)
if cc is None:
raise HTTPException(status_code=404, detail="Clamp connection not found")
label = (
f"{cc.min_mm}–{cc.max_mm} mm"
if cc.min_mm is not None
else f"{cc.min_in}–{cc.max_in} in"
)
await clamp_connection_crud.delete(db, cc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ClampConnection",
resource_id=clamp_connection_id,
description=audit_crud.describe_delete(
"ClampConnection", clamp_connection_id, label
),
)
+171
View File
@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import attachment_point as attachment_point_crud
from app.crud import audit as audit_crud
from app.crud import fit as fit_crud
from app.crud import fit_note as fit_note_crud
from app.deps import require_permission
from app.models.fit import FitLabel
from app.models.user import User
from app.schemas.fit import (
FitCreate,
FitNoteCreate,
FitNoteRead,
FitRead,
FitRecomputeResult,
FitUpdate,
)
from app.services import fit_matching
router = APIRouter()
@router.get("", response_model=list[FitRead])
async def list_fits(
augmented_part_id: int | None = None,
label: FitLabel | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
if augmented_part_id is not None:
fits = await fit_crud.list_for_part(db, augmented_part_id)
if label is not None:
fits = [f for f in fits if f.label == label]
return fits[skip : skip + limit]
return await fit_crud.list_all(db, skip=skip, limit=limit, label=label)
@router.get("/{fit_id}", response_model=FitRead)
async def get_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("fits:read")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return fit
@router.post("", response_model=FitRead, status_code=status.HTTP_201_CREATED)
async def create_fit(
payload: FitCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Manually declare a fit between two attachment points; recorded as Exact."""
if payload.attachment_point_a_id == payload.attachment_point_b_id:
raise HTTPException(
status_code=422, detail="attachment points must be different"
)
ap_a = await attachment_point_crud.get(db, payload.attachment_point_a_id)
ap_b = await attachment_point_crud.get(db, payload.attachment_point_b_id)
if ap_a is None or ap_b is None:
raise HTTPException(status_code=404, detail="Attachment point not found")
if ap_a.augmented_part_id == ap_b.augmented_part_id:
raise HTTPException(
status_code=422, detail="a part cannot fit itself"
)
existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b)
if existing is not None:
raise HTTPException(status_code=409, detail="Fit already exists")
fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_create(
"Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}"
),
)
return fit
@router.patch("/{fit_id}", response_model=FitRead)
async def update_fit_label(
fit_id: int,
payload: FitUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Promote a Possible fit to Exact, or mark a pairing as None (impossible)."""
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
fit = await fit_crud.set_label(db, fit, payload.label)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=fit.id,
description=audit_crud.describe_update(
"Fit", fit.id, {"label": payload.label.value}
),
)
return fit
@router.delete("/{fit_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fit(
fit_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:delete")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}"
await fit_crud.delete(db, fit)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Fit",
resource_id=fit_id,
description=audit_crud.describe_delete("Fit", fit_id, label),
)
@router.post("/recompute", response_model=FitRecomputeResult)
async def recompute_fits(
augmented_part_id: int | None = None,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
"""Re-run the matching algorithm, scoped to one part or across all parts."""
if augmented_part_id is not None:
result = await fit_matching.recompute_for_part(db, augmented_part_id)
else:
result = await fit_matching.recompute_all(db)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Fit",
resource_id=augmented_part_id,
description=f"Recomputed fits: {result.created} created, {result.removed} removed",
)
return FitRecomputeResult(created=result.created, removed=result.removed)
@router.post(
"/{fit_id}/notes", response_model=FitNoteRead, status_code=status.HTTP_201_CREATED
)
async def create_fit_note(
fit_id: int,
payload: FitNoteCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("fits:write")),
):
fit = await fit_crud.get(db, fit_id)
if fit is None:
raise HTTPException(status_code=404, detail="Fit not found")
return await fit_note_crud.create(db, fit=fit, author=actor, body=payload.body)
+139
View File
@@ -0,0 +1,139 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as config_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.imported_part import (
ImportedPartConfigRead,
ImportedPartConfigUpdate,
ImportedPartRead,
SyncResult,
)
from app.services import odata as odata_service
router = APIRouter()
@router.get("", response_model=list[ImportedPartRead])
async def list_imported_parts(
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
return await imported_part_crud.list_all(db, skip=skip, limit=limit)
@router.get("/config", response_model=ImportedPartConfigRead)
async def get_config(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if config is None:
return ImportedPartConfigRead(odata_url=None, username=None, has_password=False)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.patch("/config", response_model=ImportedPartConfigRead)
async def update_config(
payload: ImportedPartConfigUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
current = await config_crud.get(db)
# Keep existing password if not provided in the update
new_password = (
payload.password
if payload.password is not None
else (current.password if current else None)
)
config = await config_crud.upsert(
db,
odata_url=payload.odata_url,
username=payload.username,
password=new_password,
)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ImportedPartConfig",
resource_id=1,
description="Updated ImportedPart OData configuration",
)
return ImportedPartConfigRead(
odata_url=config.odata_url,
username=config.username,
has_password=bool(config.password),
)
@router.post("/refresh", response_model=SyncResult)
async def refresh_imported_parts(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:write")),
):
config = await config_crud.get(db)
if not config or not config.odata_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="OData URL is not configured.",
)
records = await odata_service.fetch(
config.odata_url, config.username, config.password
)
created, updated = await imported_part_crud.upsert_bulk(db, records)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ImportedPart",
resource_id=None,
description=f"OData sync: {created} created, {updated} updated ({len(records)} total records)",
)
return SyncResult(created=created, updated=updated, total=len(records))
@router.get("/{inventory_id}", response_model=ImportedPartRead)
async def get_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("imported_parts:read")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
return part
@router.delete("/{inventory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_imported_part(
inventory_id: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("imported_parts:delete")),
):
part = await imported_part_crud.get(db, inventory_id)
if part is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
await imported_part_crud.delete(db, part)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ImportedPart",
resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
)
+45 -8
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import iso_thread as iso_thread_crud
from app.deps import require_permission
from app.models.user import User
@@ -24,14 +25,25 @@ async def list_iso_threads(
async def create_iso_thread(
payload: IsoThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
actor: User = Depends(require_permission("iso_threads:write")),
):
return await iso_thread_crud.create(
thread = await iso_thread_crud.create(
db,
diameter_mm=payload.diameter_mm,
pitch_mm=payload.pitch_mm,
depth_mm=payload.depth_mm,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_create(
"IsoThread", thread.id, f"M{thread.diameter_mm}×{thread.pitch_mm}"
),
)
return thread
@router.get("/{iso_thread_id}", response_model=IsoThreadRead)
@@ -42,7 +54,9 @@ async def get_iso_thread(
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
return thread
@@ -51,21 +65,44 @@ async def update_iso_thread(
iso_thread_id: int,
payload: IsoThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:write")),
actor: User = Depends(require_permission("iso_threads:write")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
return await iso_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
changes = payload.model_dump(exclude_unset=True)
thread = await iso_thread_crud.update(db, thread, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="IsoThread",
resource_id=thread.id,
description=audit_crud.describe_update("IsoThread", thread.id, changes),
)
return thread
@router.delete("/{iso_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_iso_thread(
iso_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("iso_threads:delete")),
actor: User = Depends(require_permission("iso_threads:delete")),
):
thread = await iso_thread_crud.get(db, iso_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found"
)
name = f"M{thread.diameter_mm}×{thread.pitch_mm}"
await iso_thread_crud.delete(db, thread)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="IsoThread",
resource_id=iso_thread_id,
description=audit_crud.describe_delete("IsoThread", iso_thread_id, name),
)
+114
View File
@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import manufacturer as manufacturer_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.manufacturer import (
ManufacturerCreate,
ManufacturerRead,
ManufacturerUpdate,
)
router = APIRouter()
@router.get("", response_model=list[ManufacturerRead])
async def list_manufacturers(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
return await manufacturer_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ManufacturerRead, status_code=status.HTTP_201_CREATED)
async def create_manufacturer(
payload: ManufacturerCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.create(
db,
name=payload.name,
url=str(payload.url),
logo_url=str(payload.logo_url) if payload.logo_url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_create("Manufacturer", m.id, m.name),
)
return m
@router.get("/{manufacturer_id}", response_model=ManufacturerRead)
async def get_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("manufacturers:read")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
return m
@router.patch("/{manufacturer_id}", response_model=ManufacturerRead)
async def update_manufacturer(
manufacturer_id: int,
payload: ManufacturerUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:write")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
if "logo_url" in data and data["logo_url"] is not None:
data["logo_url"] = str(data["logo_url"])
m = await manufacturer_crud.update(db, m, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Manufacturer",
resource_id=m.id,
description=audit_crud.describe_update("Manufacturer", m.id, data),
)
return m
@router.delete("/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_manufacturer(
manufacturer_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("manufacturers:delete")),
):
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Manufacturer not found"
)
name = m.name
await manufacturer_crud.delete(db, m)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Manufacturer",
resource_id=manufacturer_id,
description=audit_crud.describe_delete("Manufacturer", manufacturer_id, name),
)
+95
View File
@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import part_colour as part_colour_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.part_colour import PartColourCreate, PartColourRead, PartColourUpdate
router = APIRouter()
@router.get("", response_model=list[PartColourRead])
async def list_part_colours(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
return await part_colour_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=PartColourRead, status_code=status.HTTP_201_CREATED)
async def create_part_colour(
payload: PartColourCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_create("PartColour", pc.id, pc.name),
)
return pc
@router.get("/{part_colour_id}", response_model=PartColourRead)
async def get_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_colours:read")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
return pc
@router.patch("/{part_colour_id}", response_model=PartColourRead)
async def update_part_colour(
part_colour_id: int,
payload: PartColourUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
changes = payload.model_dump(exclude_unset=True)
pc = await part_colour_crud.update(db, pc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="PartColour",
resource_id=pc.id,
description=audit_crud.describe_update("PartColour", pc.id, changes),
)
return pc
@router.delete("/{part_colour_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part_colour(
part_colour_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:delete")),
):
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
name = pc.name
await part_colour_crud.delete(db, pc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="PartColour",
resource_id=part_colour_id,
description=audit_crud.describe_delete("PartColour", part_colour_id, name),
)
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import part_type as part_type_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.part_type import PartTypeCreate, PartTypeRead, PartTypeUpdate
router = APIRouter()
@router.get("", response_model=list[PartTypeRead])
async def list_part_types(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_types:read")),
):
return await part_type_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=PartTypeRead, status_code=status.HTTP_201_CREATED)
async def create_part_type(
payload: PartTypeCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")),
):
pt = await part_type_crud.create(db, name=payload.name)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="PartType",
resource_id=pt.id,
description=audit_crud.describe_create("PartType", pt.id, pt.name),
)
return pt
@router.get("/{part_type_id}", response_model=PartTypeRead)
async def get_part_type(
part_type_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("part_types:read")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
return pt
@router.patch("/{part_type_id}", response_model=PartTypeRead)
async def update_part_type(
part_type_id: int,
payload: PartTypeUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
changes = payload.model_dump(exclude_unset=True)
pt = await part_type_crud.update(db, pt, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="PartType",
resource_id=pt.id,
description=audit_crud.describe_update("PartType", pt.id, changes),
)
return pt
@router.delete("/{part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_part_type(
part_type_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:delete")),
):
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
name = pt.name
await part_type_crud.delete(db, pt)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="PartType",
resource_id=part_type_id,
description=audit_crud.describe_delete("PartType", part_type_id, name),
)
+34 -5
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import role as role_crud
from app.deps import require_permission
from app.models.user import User
@@ -22,18 +23,27 @@ async def list_roles(
async def create_role(
payload: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
if await role_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
return await role_crud.create(
role = await role_crud.create(
db,
name=payload.name,
description=payload.description,
permission_ids=payload.permission_ids,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_create("Role", role.id, role.name),
)
return role
@router.get("/permissions", response_model=list[PermissionRead])
@@ -63,25 +73,44 @@ async def update_role(
role_id: int,
payload: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
)
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
changes = payload.model_dump(exclude_unset=True)
role = await role_crud.update(db, role, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Role",
resource_id=role.id,
description=audit_crud.describe_update("Role", role.id, changes),
)
return role
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
actor: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
)
name = role.name
await role_crud.delete(db, role)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="Role",
resource_id=role_id,
description=audit_crud.describe_delete("Role", role_id, name),
)
+65 -3
View File
@@ -1,10 +1,72 @@
from fastapi import APIRouter
from app.api import auth, iso_threads, roles, unc_threads, users
from app.api import (
attachment_point_names,
attachment_points,
audit_log,
augmented_parts,
auth,
clamp_connections,
fits,
imported_parts,
iso_threads,
manufacturers,
part_colours,
part_types,
roles,
scope_import,
scope_models,
static_connections,
sub_part_types,
unc_threads,
users,
)
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso-threads"])
api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"])
api_router.include_router(
iso_threads.router, prefix="/iso-threads", tags=["iso-threads"]
)
api_router.include_router(
unc_threads.router, prefix="/unc-threads", tags=["unc-threads"]
)
api_router.include_router(
manufacturers.router, prefix="/manufacturers", tags=["manufacturers"]
)
api_router.include_router(
scope_models.router, prefix="/scope-models", tags=["scope-models"]
)
api_router.include_router(
part_colours.router, prefix="/part-colours", tags=["part-colours"]
)
api_router.include_router(part_types.router, prefix="/part-types", tags=["part-types"])
api_router.include_router(
sub_part_types.router, prefix="/sub-part-types", tags=["sub-part-types"]
)
api_router.include_router(
static_connections.router, prefix="/static-connections", tags=["static-connections"]
)
api_router.include_router(
clamp_connections.router, prefix="/clamp-connections", tags=["clamp-connections"]
)
api_router.include_router(
attachment_point_names.router,
prefix="/attachment-point-names",
tags=["attachment-point-names"],
)
api_router.include_router(
attachment_points.router, prefix="/attachment-points", tags=["attachment-points"]
)
api_router.include_router(
imported_parts.router, prefix="/imported-parts", tags=["imported-parts"]
)
api_router.include_router(
augmented_parts.router, prefix="/augmented-parts", tags=["augmented-parts"]
)
api_router.include_router(audit_log.router, prefix="/audit-log", tags=["audit-log"])
api_router.include_router(fits.router, prefix="/fits", tags=["fits"])
api_router.include_router(
scope_import.router, prefix="/scope-import", tags=["scope-import"]
)
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_import import ScopeImportResult
from app.services import scope_import as scope_import_service
router = APIRouter()
_XLSX_TYPES = {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/octet-stream",
"",
}
@router.post("", response_model=ScopeImportResult)
async def import_scopes(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
filename = file.filename or ""
if not filename.lower().endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Expected an .xlsx spreadsheet.",
)
data = await file.read()
try:
result = await scope_import_service.import_from_bytes(db, data)
except Exception as exc: # openpyxl / malformed workbook
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not read spreadsheet: {exc}",
) from exc
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Scope import ({filename}): {result.created} created, "
f"{result.updated} updated, {result.skipped} skipped, "
f"{result.errors} errors"
),
)
return result
+108
View File
@@ -0,0 +1,108 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import scope_model as scope_model_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.scope_model import ScopeModelCreate, ScopeModelRead, ScopeModelUpdate
router = APIRouter()
@router.get("", response_model=list[ScopeModelRead])
async def list_scope_models(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
return await scope_model_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=ScopeModelRead, status_code=status.HTTP_201_CREATED)
async def create_scope_model(
payload: ScopeModelCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.create(
db,
name=payload.name,
manufacturer_id=payload.manufacturer_id,
url=str(payload.url) if payload.url else None,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_create("ScopeModel", sm.id, sm.name),
)
return sm
@router.get("/{scope_model_id}", response_model=ScopeModelRead)
async def get_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("scope_models:read")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
return sm
@router.patch("/{scope_model_id}", response_model=ScopeModelRead)
async def update_scope_model(
scope_model_id: int,
payload: ScopeModelUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:write")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
data = payload.model_dump(exclude_unset=True)
if "url" in data and data["url"] is not None:
data["url"] = str(data["url"])
sm = await scope_model_crud.update(db, sm, data)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="ScopeModel",
resource_id=sm.id,
description=audit_crud.describe_update("ScopeModel", sm.id, data),
)
return sm
@router.delete("/{scope_model_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_scope_model(
scope_model_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("scope_models:delete")),
):
sm = await scope_model_crud.get(db, scope_model_id)
if sm is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scope model not found"
)
name = sm.name
await scope_model_crud.delete(db, sm)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="ScopeModel",
resource_id=scope_model_id,
description=audit_crud.describe_delete("ScopeModel", scope_model_id, name),
)
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import static_connection as static_connection_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.static_connection import (
StaticConnectionCreate,
StaticConnectionRead,
StaticConnectionUpdate,
)
router = APIRouter()
@router.get("", response_model=list[StaticConnectionRead])
async def list_static_connections(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
return await static_connection_crud.list_all(db, skip=skip, limit=limit)
@router.post(
"", response_model=StaticConnectionRead, status_code=status.HTTP_201_CREATED
)
async def create_static_connection(
payload: StaticConnectionCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.create(
db, od_mm=payload.od_mm, od_in=payload.od_in
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_create(
"StaticConnection", sc.id, f"{sc.od_mm} mm"
),
)
return sc
@router.get("/{static_connection_id}", response_model=StaticConnectionRead)
async def get_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("static_connections:read")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
return sc
@router.patch("/{static_connection_id}", response_model=StaticConnectionRead)
async def update_static_connection(
static_connection_id: int,
payload: StaticConnectionUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
changes = payload.model_dump(exclude_unset=True)
sc = await static_connection_crud.update(db, sc, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="StaticConnection",
resource_id=sc.id,
description=audit_crud.describe_update("StaticConnection", sc.id, changes),
)
return sc
@router.delete("/{static_connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_static_connection(
static_connection_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:delete")),
):
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
od_mm = sc.od_mm
await static_connection_crud.delete(db, sc)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="StaticConnection",
resource_id=static_connection_id,
description=audit_crud.describe_delete(
"StaticConnection", static_connection_id, f"{od_mm} mm"
),
)
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import sub_part_type as sub_part_type_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.sub_part_type import (
SubPartTypeCreate,
SubPartTypeRead,
SubPartTypeUpdate,
)
router = APIRouter()
@router.get("", response_model=list[SubPartTypeRead])
async def list_sub_part_types(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
return await sub_part_type_crud.list_all(db, skip=skip, limit=limit)
@router.post("", response_model=SubPartTypeRead, status_code=status.HTTP_201_CREATED)
async def create_sub_part_type(
payload: SubPartTypeCreate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.create(
db, name=payload.name, part_type_id=payload.part_type_id
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_create("SubPartType", spt.id, spt.name),
)
return spt
@router.get("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def get_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("sub_part_types:read")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
return spt
@router.patch("/{sub_part_type_id}", response_model=SubPartTypeRead)
async def update_sub_part_type(
sub_part_type_id: int,
payload: SubPartTypeUpdate,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
changes = payload.model_dump(exclude_unset=True)
spt = await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="SubPartType",
resource_id=spt.id,
description=audit_crud.describe_update("SubPartType", spt.id, changes),
)
return spt
@router.delete("/{sub_part_type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_sub_part_type(
sub_part_type_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:delete")),
):
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
name = spt.name
await sub_part_type_crud.delete(db, spt)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="SubPartType",
resource_id=sub_part_type_id,
description=audit_crud.describe_delete("SubPartType", sub_part_type_id, name),
)
+47 -8
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import unc_thread as unc_thread_crud
from app.deps import require_permission
from app.models.user import User
@@ -24,15 +25,28 @@ async def list_unc_threads(
async def create_unc_thread(
payload: UncThreadCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
actor: User = Depends(require_permission("unc_threads:write")),
):
return await unc_thread_crud.create(
thread = await unc_thread_crud.create(
db,
diameter_fraction=payload.diameter_fraction,
tpi=payload.tpi,
series=payload.series,
depth_in=payload.depth_in,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="UncThread",
resource_id=thread.id,
description=audit_crud.describe_create(
"UncThread",
thread.id,
f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}",
),
)
return thread
@router.get("/{unc_thread_id}", response_model=UncThreadRead)
@@ -43,7 +57,9 @@ async def get_unc_thread(
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
return thread
@@ -52,21 +68,44 @@ async def update_unc_thread(
unc_thread_id: int,
payload: UncThreadUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:write")),
actor: User = Depends(require_permission("unc_threads:write")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
return await unc_thread_crud.update(db, thread, payload.model_dump(exclude_unset=True))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
changes = payload.model_dump(exclude_unset=True)
thread = await unc_thread_crud.update(db, thread, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="UncThread",
resource_id=thread.id,
description=audit_crud.describe_update("UncThread", thread.id, changes),
)
return thread
@router.delete("/{unc_thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_unc_thread(
unc_thread_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("unc_threads:delete")),
actor: User = Depends(require_permission("unc_threads:delete")),
):
thread = await unc_thread_crud.get(db, unc_thread_id)
if thread is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found"
)
name = f"{thread.diameter_fraction}-{thread.tpi} {thread.series.value}"
await unc_thread_crud.delete(db, thread)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="UncThread",
resource_id=unc_thread_id,
description=audit_crud.describe_delete("UncThread", unc_thread_id, name),
)
+42 -13
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import audit as audit_crud
from app.crud import user as user_crud
from app.deps import get_current_user, require_permission
from app.models.user import User
@@ -29,13 +30,13 @@ async def list_users(
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
actor: User = Depends(require_permission("users:write")),
):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
return await user_crud.create(
created = await user_crud.create(
db,
email=payload.email,
password=payload.password,
@@ -43,6 +44,15 @@ async def create_user(
is_superuser=payload.is_superuser,
role_ids=payload.role_ids,
)
await audit_crud.log(
db,
actor=actor,
action="create",
resource_type="User",
resource_id=created.id,
description=audit_crud.describe_create("User", created.id, created.email),
)
return created
@router.get("/{user_id}", response_model=UserRead)
@@ -51,12 +61,12 @@ async def get_user(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
return target
@router.patch("/{user_id}", response_model=UserRead)
@@ -64,25 +74,44 @@ async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
actor: User = Depends(require_permission("users:write")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
changes = payload.model_dump(exclude_unset=True)
target = await user_crud.update(db, target, changes)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="User",
resource_id=user_id,
description=audit_crud.describe_update("User", user_id, changes),
)
return target
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:delete")),
actor: User = Depends(require_permission("users:delete")),
):
user = await user_crud.get(db, user_id)
if user is None:
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
await user_crud.delete(db, user)
email = target.email
await user_crud.delete(db, target)
await audit_crud.log(
db,
actor=actor,
action="delete",
resource_type="User",
resource_id=user_id,
description=audit_crud.describe_delete("User", user_id, email),
)
+13
View File
@@ -37,6 +37,19 @@ class Settings(BaseSettings):
cookie_name: str = "access_token"
cookie_secure: bool = False
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
# ``{username}`` substituted) and bound directly with their password.
ldap_enabled: bool = False
ldap_host: str = "localhost"
ldap_port: int = 389
ldap_use_ssl: bool = False
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
ldap_email_domain: str = "example.com"
# LDAP attribute read for the provisioned user's display name.
ldap_full_name_attr: str = "cn"
# CORS
cors_origins: list[str] = ["*"]
+76
View File
@@ -0,0 +1,76 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint
_RELATIONSHIPS = [
"attachment_point_name",
"iso_thread",
"unc_thread",
"clamp_connection",
"static_connection",
]
async def get(db: AsyncSession, attachment_point_id: int) -> AttachmentPoint | None:
return await db.get(AttachmentPoint, attachment_point_id)
async def list_for_augmented_part(
db: AsyncSession, augmented_part_id: int
) -> list[AttachmentPoint]:
result = await db.execute(
select(AttachmentPoint)
.where(AttachmentPoint.augmented_part_id == augmented_part_id)
.order_by(AttachmentPoint.id)
)
return list(result.scalars().all())
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPoint]:
result = await db.execute(
select(AttachmentPoint)
.order_by(AttachmentPoint.augmented_part_id, AttachmentPoint.id)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
augmented_part_id: int,
attachment_point_name_id: int,
iso_thread_id: int | None = None,
unc_thread_id: int | None = None,
clamp_connection_id: int | None = None,
static_connection_id: int | None = None,
) -> AttachmentPoint:
ap = AttachmentPoint(
augmented_part_id=augmented_part_id,
attachment_point_name_id=attachment_point_name_id,
iso_thread_id=iso_thread_id,
unc_thread_id=unc_thread_id,
clamp_connection_id=clamp_connection_id,
static_connection_id=static_connection_id,
)
db.add(ap)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def update(db: AsyncSession, ap: AttachmentPoint, data: dict) -> AttachmentPoint:
for field, value in data.items():
setattr(ap, field, value)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def delete(db: AsyncSession, ap: AttachmentPoint) -> None:
await db.delete(ap)
await db.commit()
+50
View File
@@ -0,0 +1,50 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point_name import AttachmentPointName
async def get(db: AsyncSession, apn_id: int) -> AttachmentPointName | None:
return await db.get(AttachmentPointName, apn_id)
async def get_by_name(db: AsyncSession, name: str) -> AttachmentPointName | None:
result = await db.execute(
select(AttachmentPointName).where(AttachmentPointName.name == name)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AttachmentPointName]:
result = await db.execute(
select(AttachmentPointName)
.order_by(AttachmentPointName.name)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> AttachmentPointName:
apn = AttachmentPointName(name=name)
db.add(apn)
await db.commit()
await db.refresh(apn)
return apn
async def update(
db: AsyncSession, apn: AttachmentPointName, data: dict
) -> AttachmentPointName:
for field, value in data.items():
setattr(apn, field, value)
await db.commit()
await db.refresh(apn)
return apn
async def delete(db: AsyncSession, apn: AttachmentPointName) -> None:
await db.delete(apn)
await db.commit()
+48
View File
@@ -0,0 +1,48 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
from app.models.user import User
def describe_create(resource_type: str, resource_id: int, name: str) -> str:
return f"Created {resource_type} '{name}' (#{resource_id})"
def describe_update(resource_type: str, resource_id: int, changes: dict) -> str:
safe = {k: ("***" if k == "password" else v) for k, v in changes.items()}
parts = ", ".join(f"{k}={v!r}" for k, v in safe.items())
suffix = f": {parts}" if parts else ""
return f"Updated {resource_type} #{resource_id}{suffix}"
def describe_delete(resource_type: str, resource_id: int, name: str) -> str:
return f"Deleted {resource_type} '{name}' (#{resource_id})"
async def log(
db: AsyncSession,
*,
actor: User,
action: str,
resource_type: str,
resource_id: int | None,
description: str,
) -> None:
entry = AuditLog(
user_id=actor.id,
user_email=actor.email,
action=action,
resource_type=resource_type,
resource_id=resource_id,
description=description,
)
db.add(entry)
await db.commit()
async def list_recent(db: AsyncSession, limit: int = 200) -> list[AuditLog]:
result = await db.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
+203
View File
@@ -0,0 +1,203 @@
from sqlalchemy import distinct, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
async def get(db: AsyncSession, augmented_part_id: int) -> AugmentedPart | None:
return await db.get(AugmentedPart, augmented_part_id)
async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
result = await db.execute(
select(AugmentedPart).where(AugmentedPart.sku == sku).order_by(AugmentedPart.id)
)
return result.scalars().first()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[AugmentedPart]:
result = await db.execute(
select(AugmentedPart)
.order_by(AugmentedPart.description)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def list_filtered(
db: AsyncSession,
description: str = "",
colour_id: int | None = None,
manufacturer_id: int | None = None,
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
skip: int = 0,
limit: int = 100,
) -> list[AugmentedPart]:
q = select(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
if scope_model_id is not None:
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
if part_type_id is not None:
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
result = await db.execute(
q.order_by(AugmentedPart.description).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def count_filtered(
db: AsyncSession,
description: str = "",
colour_id: int | None = None,
manufacturer_id: int | None = None,
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
) -> int:
from sqlalchemy import func
q = select(func.count()).select_from(AugmentedPart)
if description.strip():
q = q.where(AugmentedPart.description.ilike(f"%{description.strip()}%"))
if colour_id is not None:
q = q.where(AugmentedPart.colour_id == colour_id)
if manufacturer_id is not None:
q = q.where(AugmentedPart.manufacturer_id == manufacturer_id)
if scope_model_id is not None:
q = q.where(AugmentedPart.scope_model_id == scope_model_id)
if part_type_id is not None:
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
result = await db.execute(q)
return result.scalar_one()
async def get_unlinked_imported_parts_filtered(
db: AsyncSession,
description: str = "",
colour: str = "",
skip: int = 0,
limit: int = 50,
) -> list[ImportedPart]:
q = select(ImportedPart).where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(
q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def count_unlinked_filtered(
db: AsyncSession,
description: str = "",
colour: str = "",
) -> int:
from sqlalchemy import func
q = (
select(func.count())
.select_from(ImportedPart)
.where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
if colour.strip():
q = q.where(ImportedPart.TBXColour.ilike(f"%{colour.strip()}%"))
result = await db.execute(q)
return result.scalar_one()
async def distinct_unlinked_colours(db: AsyncSession) -> list[str]:
q = (
select(distinct(ImportedPart.TBXColour))
.where(
ImportedPart.TBXColour.isnot(None),
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
),
)
.order_by(ImportedPart.TBXColour)
)
result = await db.execute(q)
return [row[0] for row in result.all()]
async def create(
db: AsyncSession,
*,
sku: str | None,
description: str,
colour_id: int,
manufacturer_id: int,
scope_model_id: int | None,
part_type_id: int,
sub_part_type_id: int | None,
) -> AugmentedPart:
ap = AugmentedPart(
sku=sku,
description=description,
colour_id=colour_id,
manufacturer_id=manufacturer_id,
scope_model_id=scope_model_id,
part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id,
)
db.add(ap)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def update(db: AsyncSession, ap: AugmentedPart, data: dict) -> AugmentedPart:
for field, value in data.items():
setattr(ap, field, value)
await db.commit()
await db.refresh(ap, attribute_names=_RELATIONSHIPS)
return ap
async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
"""ImportedParts that have no corresponding AugmentedPart link."""
result = await db.execute(
select(ImportedPart)
.where(
ImportedPart.InventoryID.not_in(
select(augmented_part_imported_parts.c.imported_part_id)
)
)
.order_by(ImportedPart.InventoryID)
)
return list(result.scalars().all())
async def delete(db: AsyncSession, ap: AugmentedPart) -> None:
await db.delete(ap)
await db.commit()
+50
View File
@@ -0,0 +1,50 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.clamp_connection import ClampConnection
async def get(db: AsyncSession, clamp_connection_id: int) -> ClampConnection | None:
return await db.get(ClampConnection, clamp_connection_id)
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[ClampConnection]:
result = await db.execute(
select(ClampConnection)
.order_by(
ClampConnection.min_mm.nulls_last(), ClampConnection.min_in.nulls_last()
)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
min_mm: float | None,
max_mm: float | None,
min_in: float | None,
max_in: float | None,
) -> ClampConnection:
cc = ClampConnection(min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in)
db.add(cc)
await db.commit()
await db.refresh(cc)
return cc
async def update(db: AsyncSession, cc: ClampConnection, data: dict) -> ClampConnection:
for field, value in data.items():
setattr(cc, field, value)
await db.commit()
await db.refresh(cc)
return cc
async def delete(db: AsyncSession, cc: ClampConnection) -> None:
await db.delete(cc)
await db.commit()
+98
View File
@@ -0,0 +1,98 @@
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment_point import AttachmentPoint
from app.models.fit import Fit, FitLabel
# "updated_at" is included because it's a server-side onupdate default: after an
# UPDATE, SQLAlchemy doesn't know its new value unless it's named explicitly here
# too, and access to an unrefreshed attribute would otherwise trigger a lazy load
# outside of an awaited context (MissingGreenlet) during response serialization.
_RELATIONSHIPS = ["attachment_point_a", "attachment_point_b", "notes", "updated_at"]
def _ordered(
ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> tuple[AttachmentPoint, AttachmentPoint]:
"""Canonicalize so attachment_point_a.id < attachment_point_b.id.
Keeps a single row per unordered pair of attachment points.
"""
return (ap_a, ap_b) if ap_a.id < ap_b.id else (ap_b, ap_a)
async def get(db: AsyncSession, fit_id: int) -> Fit | None:
return await db.get(Fit, fit_id)
async def get_by_attachment_points(
db: AsyncSession, ap_a: AttachmentPoint, ap_b: AttachmentPoint
) -> Fit | None:
first, second = _ordered(ap_a, ap_b)
result = await db.execute(
select(Fit).where(
Fit.attachment_point_a_id == first.id,
Fit.attachment_point_b_id == second.id,
)
)
return result.scalar_one_or_none()
async def list_for_part(db: AsyncSession, augmented_part_id: int) -> list[Fit]:
result = await db.execute(
select(Fit)
.where(
or_(
Fit.augmented_part_a_id == augmented_part_id,
Fit.augmented_part_b_id == augmented_part_id,
)
)
.order_by(Fit.id)
)
return list(result.scalars().all())
async def list_all(
db: AsyncSession,
skip: int = 0,
limit: int = 100,
label: FitLabel | None = None,
) -> list[Fit]:
q = select(Fit)
if label is not None:
q = q.where(Fit.label == label)
result = await db.execute(q.order_by(Fit.id).offset(skip).limit(limit))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
ap_a: AttachmentPoint,
ap_b: AttachmentPoint,
label: FitLabel,
) -> Fit:
first, second = _ordered(ap_a, ap_b)
fit = Fit(
attachment_point_a_id=first.id,
attachment_point_b_id=second.id,
augmented_part_a_id=first.augmented_part_id,
augmented_part_b_id=second.augmented_part_id,
label=label,
)
db.add(fit)
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def set_label(db: AsyncSession, fit: Fit, label: FitLabel) -> Fit:
fit.label = label
await db.commit()
await db.refresh(fit, attribute_names=_RELATIONSHIPS)
return fit
async def delete(db: AsyncSession, fit: Fit) -> None:
await db.delete(fit)
await db.commit()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fit import Fit, FitNote
from app.models.user import User
async def create(db: AsyncSession, *, fit: Fit, author: User, body: str) -> FitNote:
note = FitNote(
fit_id=fit.id,
user_id=author.id,
username=author.full_name or author.email,
body=body,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
+110
View File
@@ -0,0 +1,110 @@
from sqlalchemy import distinct, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part import ImportedPart
_FIELDS = frozenset(
{
"InventoryID",
"Description",
"TBXColour",
"ProductMaxThreadDepthmm",
"ProductMaxThreadDepthin",
"ProductODmm",
"ProductODin",
"TBXThreadAttribute",
}
)
async def get(db: AsyncSession, inventory_id: str) -> ImportedPart | None:
return await db.get(ImportedPart, inventory_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 500) -> list[ImportedPart]:
result = await db.execute(
select(ImportedPart).order_by(ImportedPart.InventoryID).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def list_filtered(
db: AsyncSession,
description: str = "",
skip: int = 0,
limit: int = 500,
) -> list[ImportedPart]:
q = select(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
result = await db.execute(q.order_by(ImportedPart.InventoryID).offset(skip).limit(limit))
return list(result.scalars().all())
async def distinct_colours(db: AsyncSession) -> list[str]:
result = await db.execute(
select(distinct(ImportedPart.TBXColour))
.where(ImportedPart.TBXColour.isnot(None))
.order_by(ImportedPart.TBXColour)
)
return [row[0] for row in result.all()]
async def count(db: AsyncSession) -> int:
from sqlalchemy import func
result = await db.execute(select(func.count()).select_from(ImportedPart))
return result.scalar_one()
async def count_filtered(db: AsyncSession, description: str = "") -> int:
from sqlalchemy import func
q = select(func.count()).select_from(ImportedPart)
if description.strip():
q = q.where(ImportedPart.Description.ilike(f"%{description.strip()}%"))
result = await db.execute(q)
return result.scalar_one()
async def delete(db: AsyncSession, part: ImportedPart) -> None:
await db.delete(part)
await db.commit()
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records:
return 0, 0
clean = [
{k: str(v).strip() if v is not None else None for k, v in r.items() if k in _FIELDS}
for r in records
if r.get("InventoryID")
]
if not clean:
return 0, 0
incoming_ids = {r["InventoryID"] for r in clean}
result = await db.execute(
select(ImportedPart.InventoryID).where(ImportedPart.InventoryID.in_(incoming_ids))
)
existing_ids = {row[0] for row in result.all()}
to_insert = [r for r in clean if r["InventoryID"] not in existing_ids]
to_update = [r for r in clean if r["InventoryID"] in existing_ids]
if to_insert:
await db.execute(insert(ImportedPart), to_insert)
for record in to_update:
inv_id = record["InventoryID"]
vals = {k: v for k, v in record.items() if k != "InventoryID"}
if vals:
await db.execute(
update(ImportedPart).where(ImportedPart.InventoryID == inv_id).values(vals)
)
await db.commit()
return len(to_insert), len(to_update)
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imported_part_config import ImportedPartConfig
_SINGLETON_ID = 1
async def get(db: AsyncSession) -> ImportedPartConfig | None:
return await db.get(ImportedPartConfig, _SINGLETON_ID)
async def upsert(
db: AsyncSession,
*,
odata_url: str | None,
username: str | None,
password: str | None,
) -> ImportedPartConfig:
config = await db.get(ImportedPartConfig, _SINGLETON_ID)
if config is None:
config = ImportedPartConfig(
id=_SINGLETON_ID,
odata_url=odata_url,
username=username,
password=password,
)
db.add(config)
else:
config.odata_url = odata_url
config.username = username
config.password = password
await db.commit()
await db.refresh(config)
return config
+25 -1
View File
@@ -8,7 +8,31 @@ async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None:
return await db.get(IsoThread, iso_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]:
async def get_by_values(
db: AsyncSession,
*,
diameter_mm: float,
pitch_mm: float,
depth_mm: float | None,
) -> IsoThread | None:
depth_clause = (
IsoThread.depth_mm.is_(None)
if depth_mm is None
else IsoThread.depth_mm == depth_mm
)
result = await db.execute(
select(IsoThread).where(
IsoThread.diameter_mm == diameter_mm,
IsoThread.pitch_mm == pitch_mm,
depth_clause,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[IsoThread]:
result = await db.execute(
select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit)
)
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.manufacturer import Manufacturer
async def get(db: AsyncSession, manufacturer_id: int) -> Manufacturer | None:
return await db.get(Manufacturer, manufacturer_id)
async def get_by_name(db: AsyncSession, name: str) -> Manufacturer | None:
result = await db.execute(select(Manufacturer).where(Manufacturer.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[Manufacturer]:
result = await db.execute(
select(Manufacturer).order_by(Manufacturer.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
url: str,
logo_url: str | None = None,
) -> Manufacturer:
m = Manufacturer(name=name, url=url, logo_url=logo_url)
db.add(m)
await db.commit()
await db.refresh(m)
return m
async def update(
db: AsyncSession, manufacturer: Manufacturer, data: dict
) -> Manufacturer:
for field, value in data.items():
setattr(manufacturer, field, value)
await db.commit()
await db.refresh(manufacturer)
return manufacturer
async def delete(db: AsyncSession, manufacturer: Manufacturer) -> None:
await db.delete(manufacturer)
await db.commit()
+43
View File
@@ -0,0 +1,43 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.part_colour import PartColour
async def get(db: AsyncSession, part_colour_id: int) -> PartColour | None:
return await db.get(PartColour, part_colour_id)
async def get_by_name(db: AsyncSession, name: str) -> PartColour | None:
result = await db.execute(select(PartColour).where(PartColour.name == name))
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[PartColour]:
result = await db.execute(
select(PartColour).order_by(PartColour.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> PartColour:
pc = PartColour(name=name)
db.add(pc)
await db.commit()
await db.refresh(pc)
return pc
async def update(db: AsyncSession, pc: PartColour, data: dict) -> PartColour:
for field, value in data.items():
setattr(pc, field, value)
await db.commit()
await db.refresh(pc)
return pc
async def delete(db: AsyncSession, pc: PartColour) -> None:
await db.delete(pc)
await db.commit()
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.part_type import PartType
async def get(db: AsyncSession, part_type_id: int) -> PartType | None:
return await db.get(PartType, part_type_id)
async def get_by_name(db: AsyncSession, name: str) -> PartType | None:
result = await db.execute(select(PartType).where(PartType.name == name))
return result.scalar_one_or_none()
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[PartType]:
result = await db.execute(
select(PartType).order_by(PartType.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str) -> PartType:
pt = PartType(name=name)
db.add(pt)
await db.commit()
await db.refresh(pt)
return pt
async def update(db: AsyncSession, part_type: PartType, data: dict) -> PartType:
for field, value in data.items():
setattr(part_type, field, value)
await db.commit()
await db.refresh(part_type)
return part_type
async def delete(db: AsyncSession, part_type: PartType) -> None:
await db.delete(part_type)
await db.commit()
+28
View File
@@ -1,4 +1,5 @@
from sqlalchemy import select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission
@@ -14,6 +15,23 @@ async def get_by_name(db: AsyncSession, name: str) -> Role | None:
return result.scalar_one_or_none()
async def get_default(db: AsyncSession) -> Role | None:
"""The role auto-assigned to new users, or ``None`` if none is marked."""
result = await db.execute(select(Role).where(Role.is_default.is_(True)))
return result.scalars().first()
async def set_default(db: AsyncSession, role: Role) -> Role:
"""Make ``role`` the sole default, clearing the flag on every other role."""
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
await db.commit()
await db.refresh(role)
return role
async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all())
@@ -50,6 +68,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else:
data.pop("permission_ids", None)
# Setting a role default must clear the flag on all others.
make_default = data.pop("is_default", None)
if make_default:
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
elif make_default is False:
role.is_default = False
for field, value in data.items():
if value is not None:
setattr(role, field, value)
+56
View File
@@ -0,0 +1,56 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scope_info import ScopeInfo
async def get(db: AsyncSession, scope_info_id: int) -> ScopeInfo | None:
return await db.get(ScopeInfo, scope_info_id)
async def get_by_augmented_part(
db: AsyncSession, augmented_part_id: int
) -> ScopeInfo | None:
result = await db.execute(
select(ScopeInfo).where(ScopeInfo.augmented_part_id == augmented_part_id)
)
return result.scalar_one_or_none()
async def create(
db: AsyncSession,
*,
augmented_part_id: int,
magnification_min: float | None = None,
magnification_max: float | None = None,
model_suffix: str | None = None,
objective_thread_pitch: str | None = None,
) -> ScopeInfo:
info = ScopeInfo(
augmented_part_id=augmented_part_id,
magnification_min=magnification_min,
magnification_max=magnification_max,
model_suffix=model_suffix,
objective_thread_pitch=objective_thread_pitch,
)
db.add(info)
await db.commit()
await db.refresh(info)
return info
async def upsert(db: AsyncSession, *, augmented_part_id: int, **fields) -> ScopeInfo:
"""Create the ScopeInfo for an AugmentedPart, or update it if it exists."""
info = await get_by_augmented_part(db, augmented_part_id)
if info is None:
return await create(db, augmented_part_id=augmented_part_id, **fields)
for field, value in fields.items():
setattr(info, field, value)
await db.commit()
await db.refresh(info)
return info
async def delete(db: AsyncSession, info: ScopeInfo) -> None:
await db.delete(info)
await db.commit()
+56
View File
@@ -0,0 +1,56 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.scope_model import ScopeModel
async def get(db: AsyncSession, scope_model_id: int) -> ScopeModel | None:
return await db.get(ScopeModel, scope_model_id)
async def get_by_name_and_manufacturer(
db: AsyncSession, name: str, manufacturer_id: int
) -> ScopeModel | None:
result = await db.execute(
select(ScopeModel).where(
ScopeModel.name == name,
ScopeModel.manufacturer_id == manufacturer_id,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[ScopeModel]:
result = await db.execute(
select(ScopeModel).order_by(ScopeModel.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
manufacturer_id: int,
url: str | None = None,
) -> ScopeModel:
sm = ScopeModel(name=name, manufacturer_id=manufacturer_id, url=url)
db.add(sm)
await db.commit()
await db.refresh(sm, attribute_names=["manufacturer"])
return sm
async def update(db: AsyncSession, scope_model: ScopeModel, data: dict) -> ScopeModel:
for field, value in data.items():
setattr(scope_model, field, value)
await db.commit()
await db.refresh(scope_model, attribute_names=["manufacturer"])
return scope_model
async def delete(db: AsyncSession, scope_model: ScopeModel) -> None:
await db.delete(scope_model)
await db.commit()
+55
View File
@@ -0,0 +1,55 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.static_connection import StaticConnection
async def get(db: AsyncSession, static_connection_id: int) -> StaticConnection | None:
return await db.get(StaticConnection, static_connection_id)
async def get_by_od_mm(db: AsyncSession, od_mm: float) -> StaticConnection | None:
result = await db.execute(
select(StaticConnection).where(StaticConnection.od_mm == od_mm)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[StaticConnection]:
result = await db.execute(
select(StaticConnection)
.order_by(StaticConnection.od_mm)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
od_mm: float,
od_in: float,
) -> StaticConnection:
sc = StaticConnection(od_mm=od_mm, od_in=od_in)
db.add(sc)
await db.commit()
await db.refresh(sc)
return sc
async def update(
db: AsyncSession, sc: StaticConnection, data: dict
) -> StaticConnection:
for field, value in data.items():
setattr(sc, field, value)
await db.commit()
await db.refresh(sc)
return sc
async def delete(db: AsyncSession, sc: StaticConnection) -> None:
await db.delete(sc)
await db.commit()
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sub_part_type import SubPartType
async def get(db: AsyncSession, sub_part_type_id: int) -> SubPartType | None:
return await db.get(SubPartType, sub_part_type_id)
async def get_by_name_and_part_type(
db: AsyncSession, name: str, part_type_id: int
) -> SubPartType | None:
result = await db.execute(
select(SubPartType).where(
SubPartType.name == name, SubPartType.part_type_id == part_type_id
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[SubPartType]:
result = await db.execute(
select(SubPartType).order_by(SubPartType.name).offset(skip).limit(limit)
)
return list(result.scalars().all())
async def create(db: AsyncSession, *, name: str, part_type_id: int) -> SubPartType:
spt = SubPartType(name=name, part_type_id=part_type_id)
db.add(spt)
await db.commit()
await db.refresh(spt, attribute_names=["part_type"])
return spt
async def update(
db: AsyncSession, sub_part_type: SubPartType, data: dict
) -> SubPartType:
for field, value in data.items():
setattr(sub_part_type, field, value)
await db.commit()
await db.refresh(sub_part_type, attribute_names=["part_type"])
return sub_part_type
async def delete(db: AsyncSession, sub_part_type: SubPartType) -> None:
await db.delete(sub_part_type)
await db.commit()
+27 -1
View File
@@ -8,7 +8,33 @@ async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None:
return await db.get(UncThread, unc_thread_id)
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]:
async def get_by_values(
db: AsyncSession,
*,
diameter_fraction: str,
tpi: int,
series: UncSeries,
depth_in: float | None,
) -> UncThread | None:
depth_clause = (
UncThread.depth_in.is_(None)
if depth_in is None
else UncThread.depth_in == depth_in
)
result = await db.execute(
select(UncThread).where(
UncThread.diameter_fraction == diameter_fraction,
UncThread.tpi == tpi,
UncThread.series == series,
depth_clause,
)
)
return result.scalar_one_or_none()
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
) -> list[UncThread]:
result = await db.execute(
select(UncThread).order_by(UncThread.id).offset(skip).limit(limit)
)
+47 -4
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.role import Role
from app.models.user import User
from app.models.user import AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User
async def get(db: AsyncSession, user_id: int) -> User | None:
@@ -15,6 +15,11 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
return result.scalar_one_or_none()
async def get_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all())
@@ -36,14 +41,18 @@ async def create(
db: AsyncSession,
*,
email: str,
password: str,
password: str | None = None,
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
username: str | None = None,
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
username=username,
hashed_password=hash_password(password) if password else None,
auth_source=auth_source,
full_name=full_name,
is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []),
@@ -54,6 +63,37 @@ async def create(
return user
async def get_or_create_ldap_user(
db: AsyncSession,
*,
username: str,
email: str,
full_name: str | None,
role_ids: list[int] | None = None,
) -> User:
"""Find an LDAP user by username, provisioning a local profile on first login.
The profile is keyed on the LDAP username; email is synthesized by the
caller. Newly provisioned users get ``role_ids`` (the default role).
"""
user = await get_by_username(db, username)
if user is not None:
# Keep the display name in sync with LDAP on subsequent logins.
if full_name and user.full_name != full_name:
user.full_name = full_name
await db.commit()
await db.refresh(user)
return user
return await create(
db,
email=email,
username=username,
full_name=full_name,
auth_source=AUTH_SOURCE_LDAP,
role_ids=role_ids,
)
async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password"))
@@ -81,6 +121,9 @@ async def delete(db: AsyncSession, user: User) -> None:
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if not user or not verify_password(password, user.hashed_password):
# LDAP users have no local password and must sign in via LDAP.
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None
return user
+38 -1
View File
@@ -1,20 +1,57 @@
"""Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles
from app.models.associations import (
augmented_part_imported_parts,
role_permissions,
user_roles,
)
from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart
from app.models.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.permission import Permission
from app.models.scope_info import ScopeInfo
from app.models.scope_model import ScopeModel
from app.models.static_connection import StaticConnection
from app.models.sub_part_type import SubPartType
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.unc_thread import UncThread
from app.models.user import User
__all__ = [
"AttachmentPoint",
"AttachmentPointName",
"AuditLog",
"AugmentedPart",
"ClampConnection",
"Fit",
"FitLabel",
"FitNote",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
"Manufacturer",
"PartColour",
"PartType",
"Permission",
"ScopeInfo",
"ScopeModel",
"StaticConnection",
"SubPartType",
"RefreshToken",
"Role",
"UncThread",
"User",
"augmented_part_imported_parts",
"role_permissions",
"user_roles",
]
+18 -2
View File
@@ -1,6 +1,6 @@
"""Many-to-many association tables for RBAC."""
"""Many-to-many association tables."""
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy import Column, ForeignKey, String, Table
from app.core.database import Base
@@ -21,3 +21,19 @@ role_permissions = Table(
primary_key=True,
),
)
augmented_part_imported_parts = Table(
"augmented_part_imported_parts",
Base.metadata,
Column(
"augmented_part_id",
ForeignKey("augmented_parts.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"imported_part_id",
String,
ForeignKey("imported_parts.InventoryID", ondelete="CASCADE"),
primary_key=True,
),
)
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.attachment_point_name import AttachmentPointName
from app.models.clamp_connection import ClampConnection
from app.models.iso_thread import IsoThread
from app.models.static_connection import StaticConnection
from app.models.unc_thread import UncThread
class AttachmentPoint(Base):
__tablename__ = "attachment_points"
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
attachment_point_name_id: Mapped[int] = mapped_column(
ForeignKey("attachment_point_names.id", ondelete="RESTRICT"), nullable=False
)
# Exactly one of the four below must be non-null (enforced in application layer)
iso_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("iso_threads.id", ondelete="RESTRICT"), nullable=True, default=None
)
unc_thread_id: Mapped[int | None] = mapped_column(
ForeignKey("unc_threads.id", ondelete="RESTRICT"), nullable=True, default=None
)
clamp_connection_id: Mapped[int | None] = mapped_column(
ForeignKey("clamp_connections.id", ondelete="RESTRICT"),
nullable=True,
default=None,
)
static_connection_id: Mapped[int | None] = mapped_column(
ForeignKey("static_connections.id", ondelete="RESTRICT"),
nullable=True,
default=None,
)
attachment_point_name: Mapped[AttachmentPointName] = relationship(lazy="selectin")
iso_thread: Mapped[IsoThread | None] = relationship(lazy="selectin")
unc_thread: Mapped[UncThread | None] = relationship(lazy="selectin")
clamp_connection: Mapped[ClampConnection | None] = relationship(lazy="selectin")
static_connection: Mapped[StaticConnection | None] = relationship(lazy="selectin")
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AttachmentPointName(Base):
__tablename__ = "attachment_point_names"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
+23
View File
@@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
user_email: Mapped[str] = mapped_column(String(255), nullable=False)
action: Mapped[str] = mapped_column(String(16), nullable=False)
resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
resource_id: Mapped[int | None] = mapped_column(nullable=True)
description: Mapped[str] = mapped_column(nullable=False)
+47
View File
@@ -0,0 +1,47 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import augmented_part_imported_parts
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.models.scope_model import ScopeModel
from app.models.sub_part_type import SubPartType
class AugmentedPart(Base):
__tablename__ = "augmented_parts"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
description: Mapped[str] = mapped_column(String, nullable=False)
colour_id: Mapped[int] = mapped_column(
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
)
manufacturer_id: Mapped[int] = mapped_column(
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
)
scope_model_id: Mapped[int | None] = mapped_column(
ForeignKey("scope_models.id", ondelete="SET NULL"), nullable=True, default=None
)
part_type_id: Mapped[int] = mapped_column(
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
)
sub_part_type_id: Mapped[int | None] = mapped_column(
ForeignKey("sub_part_types.id", ondelete="SET NULL"),
nullable=True,
default=None,
)
# linked imported parts — not eagerly loaded; used by sync service only
imported_parts: Mapped[list[ImportedPart]] = relationship(
secondary=augmented_part_imported_parts, lazy="select"
)
colour: Mapped[PartColour] = relationship(lazy="selectin")
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
part_type: Mapped[PartType] = relationship(lazy="selectin")
sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin")
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Float
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ClampConnection(Base):
__tablename__ = "clamp_connections"
id: Mapped[int] = mapped_column(primary_key=True)
min_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
min_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
max_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
+89
View File
@@ -0,0 +1,89 @@
import enum
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class FitLabel(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
@@ -0,0 +1,21 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPart(Base):
__tablename__ = "imported_parts"
InventoryID: Mapped[str] = mapped_column(String, primary_key=True)
Description: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXColour: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductMaxThreadDepthmm: Mapped[str | None] = mapped_column(
nullable=True, default=None
)
ProductMaxThreadDepthin: Mapped[str | None] = mapped_column(
nullable=True, default=None
)
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ImportedPartConfig(Base):
"""Singleton (id=1) storing the OData source URL and credentials."""
__tablename__ = "imported_part_config"
id: Mapped[int] = mapped_column(primary_key=True, default=1)
odata_url: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
username: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
password: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Manufacturer(Base):
__tablename__ = "manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=False)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PartColour(Base):
__tablename__ = "part_colours"
__table_args__ = (UniqueConstraint("name"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PartType(Base):
__tablename__ = "part_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -11,6 +11,9 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
# The role auto-assigned to newly provisioned/registered users. At most one
# role is the default; see ``crud.role.set_default``.
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles,
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy import Float, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ScopeInfo(Base):
"""Scope-specific attributes for an AugmentedPart of PartType "Scope".
One-to-one with AugmentedPart (unique augmented_part_id). Holds fields that
have no home on AugmentedPart itself; populated by the scope xlsx importer.
"""
__tablename__ = "scope_infos"
__table_args__ = (UniqueConstraint("augmented_part_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
augmented_part_id: Mapped[int] = mapped_column(
ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False
)
magnification_min: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
magnification_max: Mapped[float | None] = mapped_column(
Float, nullable=True, default=None
)
model_suffix: Mapped[str | None] = mapped_column(
String, nullable=True, default=None
)
# 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
@@ -0,0 +1,18 @@
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.manufacturer import Manufacturer
class ScopeModel(Base):
__tablename__ = "scope_models"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
manufacturer_id: Mapped[int] = mapped_column(
ForeignKey("manufacturers.id", ondelete="RESTRICT"), nullable=False
)
url: Mapped[str | None] = mapped_column(nullable=True, default=None)
manufacturer: Mapped[Manufacturer] = relationship(lazy="selectin")
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Float, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class StaticConnection(Base):
__tablename__ = "static_connections"
__table_args__ = (UniqueConstraint("od_mm"),)
id: Mapped[int] = mapped_column(primary_key=True)
od_mm: Mapped[float] = mapped_column(Float, nullable=False)
od_in: Mapped[float] = mapped_column(Float, nullable=False)
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.part_type import PartType
class SubPartType(Base):
__tablename__ = "sub_part_types"
__table_args__ = (UniqueConstraint("name", "part_type_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
part_type_id: Mapped[int] = mapped_column(
ForeignKey("part_types.id", ondelete="RESTRICT"), nullable=False
)
part_type: Mapped[PartType] = relationship(lazy="selectin")
+11 -1
View File
@@ -6,13 +6,23 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import user_roles
# Where a user's credentials are verified.
AUTH_SOURCE_LOCAL = "local"
AUTH_SOURCE_LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
# Set for users provisioned from LDAP; how they sign in. Null for local users.
username: Mapped[str | None] = mapped_column(
String(255), unique=True, index=True, default=None
)
# Null for LDAP users, who have no local password (verified against LDAP).
hashed_password: Mapped[str | None] = mapped_column(String(255), default=None)
auth_source: Mapped[str] = mapped_column(String(16), default=AUTH_SOURCE_LOCAL)
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.attachment_point_name import AttachmentPointNameRead
from app.schemas.clamp_connection import ClampConnectionRead
from app.schemas.iso_thread import IsoThreadRead
from app.schemas.static_connection import StaticConnectionRead
from app.schemas.unc_thread import UncThreadRead
class AttachmentPointBase(BaseModel):
augmented_part_id: int
attachment_point_name_id: int
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointCreate(AttachmentPointBase):
pass
class AttachmentPointUpdate(BaseModel):
augmented_part_id: int | None = None
attachment_point_name_id: int | None = None
iso_thread_id: int | None = None
unc_thread_id: int | None = None
clamp_connection_id: int | None = None
static_connection_id: int | None = None
class AttachmentPointRead(AttachmentPointBase):
model_config = ConfigDict(from_attributes=True)
id: int
attachment_point_name: AttachmentPointNameRead
iso_thread: IsoThreadRead | None = None
unc_thread: UncThreadRead | None = None
clamp_connection: ClampConnectionRead | None = None
static_connection: StaticConnectionRead | None = None
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, ConfigDict
class AttachmentPointNameBase(BaseModel):
name: str
class AttachmentPointNameCreate(AttachmentPointNameBase):
pass
class AttachmentPointNameUpdate(BaseModel):
name: str | None = None
class AttachmentPointNameRead(AttachmentPointNameBase):
model_config = ConfigDict(from_attributes=True)
id: int
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
user_id: int | None
user_email: str
action: str
resource_type: str
resource_id: int | None
description: str
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.manufacturer import ManufacturerRead
from app.schemas.part_colour import PartColourRead
from app.schemas.part_type import PartTypeRead
from app.schemas.scope_model import ScopeModelRead
from app.schemas.sub_part_type import SubPartTypeRead
class AugmentedPartBase(BaseModel):
sku: str | None = None
description: str
colour_id: int
manufacturer_id: int
scope_model_id: int | None = None
part_type_id: int
sub_part_type_id: int | None = None
class AugmentedPartCreate(AugmentedPartBase):
pass
class AugmentedPartUpdate(BaseModel):
sku: str | None = None
description: str | None = None
colour_id: int | None = None
manufacturer_id: int | None = None
scope_model_id: int | None = None
part_type_id: int | None = None
sub_part_type_id: int | None = None
class AugmentedPartSyncResult(BaseModel):
created: int
deleted: int
skipped: int
class AugmentedPartRead(AugmentedPartBase):
model_config = ConfigDict(from_attributes=True)
id: int
colour: PartColourRead
manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None
part_type: PartTypeRead
sub_part_type: SubPartTypeRead | None
+21
View File
@@ -0,0 +1,21 @@
from pydantic import BaseModel, ConfigDict
class ClampConnectionBase(BaseModel):
min_mm: float | None = None
max_mm: float | None = None
min_in: float | None = None
max_in: float | None = None
class ClampConnectionCreate(ClampConnectionBase):
pass
class ClampConnectionUpdate(ClampConnectionBase):
pass
class ClampConnectionRead(ClampConnectionBase):
model_config = ConfigDict(from_attributes=True)
id: int
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.fit import FitLabel
from app.schemas.attachment_point import AttachmentPointRead
class FitNoteCreate(BaseModel):
body: str
@field_validator("body")
@classmethod
def validate_body(cls, v: str) -> str:
if not v.strip():
raise ValueError("body must not be blank")
return v.strip()
class FitNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
fit_id: int
user_id: int | None
username: str
body: str
created_at: datetime
class FitCreate(BaseModel):
"""Manually-declared fit; recorded as Exact regardless of the match algorithm."""
attachment_point_a_id: int
attachment_point_b_id: int
class FitUpdate(BaseModel):
label: FitLabel
class FitRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
augmented_part_a_id: int
augmented_part_b_id: int
attachment_point_a_id: int
attachment_point_b_id: int
attachment_point_a: AttachmentPointRead
attachment_point_b: AttachmentPointRead
label: FitLabel
created_at: datetime
updated_at: datetime
notes: list[FitNoteRead] = []
class FitRecomputeResult(BaseModel):
created: int
removed: int
+34
View File
@@ -0,0 +1,34 @@
from pydantic import BaseModel, ConfigDict
class ImportedPartRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
InventoryID: str
Description: str | None
TBXColour: str | None
ProductMaxThreadDepthmm: str | None
ProductMaxThreadDepthin: str | None
ProductODmm: str | None
ProductODin: str | None
TBXThreadAttribute: str | None
class ImportedPartConfigRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
odata_url: str | None
username: str | None
has_password: bool
class ImportedPartConfigUpdate(BaseModel):
odata_url: str | None = None
username: str | None = None
password: str | None = None
class SyncResult(BaseModel):
created: int
updated: int
total: int
+23
View File
@@ -0,0 +1,23 @@
from pydantic import BaseModel, ConfigDict, HttpUrl
class ManufacturerBase(BaseModel):
name: str
url: HttpUrl
logo_url: HttpUrl | None = None
class ManufacturerCreate(ManufacturerBase):
pass
class ManufacturerUpdate(BaseModel):
name: str | None = None
url: HttpUrl | None = None
logo_url: HttpUrl | None = None
class ManufacturerRead(ManufacturerBase):
model_config = ConfigDict(from_attributes=True)
id: int
+18
View File
@@ -0,0 +1,18 @@
from pydantic import BaseModel, ConfigDict
class PartColourBase(BaseModel):
name: str
class PartColourCreate(PartColourBase):
pass
class PartColourUpdate(BaseModel):
name: str | None = None
class PartColourRead(PartColourBase):
model_config = ConfigDict(from_attributes=True)
id: int
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel, ConfigDict
class PartTypeBase(BaseModel):
name: str
class PartTypeCreate(PartTypeBase):
pass
class PartTypeUpdate(BaseModel):
name: str | None = None
class PartTypeRead(PartTypeBase):
model_config = ConfigDict(from_attributes=True)
id: int
+2
View File
@@ -12,6 +12,7 @@ class PermissionRead(BaseModel):
class RoleBase(BaseModel):
name: str
description: str | None = None
is_default: bool = False
class RoleCreate(RoleBase):
@@ -21,6 +22,7 @@ class RoleCreate(RoleBase):
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_default: bool | None = None
permission_ids: list[int] | None = None
+17
View File
@@ -0,0 +1,17 @@
from pydantic import BaseModel
class ScopeImportRowResult(BaseModel):
row: int
product_number: str | None = None
status: str # "created" | "updated" | "skipped" | "error"
messages: list[str] = []
class ScopeImportResult(BaseModel):
created: int
updated: int
skipped: int
errors: int
total: int
rows: list[ScopeImportRowResult] = []
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel, ConfigDict
class ScopeInfoBase(BaseModel):
magnification_min: float | None = None
magnification_max: float | None = None
model_suffix: str | None = None
objective_thread_pitch: str | None = None
class ScopeInfoRead(ScopeInfoBase):
model_config = ConfigDict(from_attributes=True)
id: int
augmented_part_id: int
+26
View File
@@ -0,0 +1,26 @@
from pydantic import BaseModel, ConfigDict, HttpUrl
from app.schemas.manufacturer import ManufacturerRead
class ScopeModelBase(BaseModel):
name: str
manufacturer_id: int
url: HttpUrl | None = None
class ScopeModelCreate(ScopeModelBase):
pass
class ScopeModelUpdate(BaseModel):
name: str | None = None
manufacturer_id: int | None = None
url: HttpUrl | None = None
class ScopeModelRead(ScopeModelBase):
model_config = ConfigDict(from_attributes=True)
id: int
manufacturer: ManufacturerRead
+20
View File
@@ -0,0 +1,20 @@
from pydantic import BaseModel, ConfigDict
class StaticConnectionBase(BaseModel):
od_mm: float
od_in: float
class StaticConnectionCreate(StaticConnectionBase):
pass
class StaticConnectionUpdate(BaseModel):
od_mm: float | None = None
od_in: float | None = None
class StaticConnectionRead(StaticConnectionBase):
model_config = ConfigDict(from_attributes=True)
id: int
+24
View File
@@ -0,0 +1,24 @@
from pydantic import BaseModel, ConfigDict
from app.schemas.part_type import PartTypeRead
class SubPartTypeBase(BaseModel):
name: str
part_type_id: int
class SubPartTypeCreate(SubPartTypeBase):
pass
class SubPartTypeUpdate(BaseModel):
name: str | None = None
part_type_id: int | None = None
class SubPartTypeRead(SubPartTypeBase):
model_config = ConfigDict(from_attributes=True)
id: int
part_type: PartTypeRead
+2
View File
@@ -29,6 +29,8 @@ class UserRead(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
username: str | None = None
auth_source: str = "local"
is_active: bool
is_superuser: bool
created_at: datetime
+340
View File
@@ -0,0 +1,340 @@
"""Synchronise AugmentedParts against the current ImportedParts table.
Algorithm
---------
Phase 1 – prune stale entries
• Find association rows whose imported_part_id no longer exists.
• Delete those rows.
• Delete any AugmentedPart that ends up with zero remaining associations
(i.e. it was created purely from now-deleted ImportedParts).
Phase 2 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
• Description ← ImportedPart.Description (fallback: InventoryID)
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
• Manufacturer ← "Armament" (created if needed)
• Model ← null
• Type ← first PartType whose name is a substring of the description
(longest name wins; skipped if no match)
• SubType ← null
• Attachment point ← always named "Occular"; one of:
- ISO/UNC thread parsed from TBXThreadAttribute (using the
matching ProductMaxThreadDepth mm/in if populated), or
- StaticConnection from ProductODmm/ProductODin (whichever
unit is missing is derived via mm/in conversion) if the
thread attribute is absent or unparseable
Skipped entirely if none of those fields are usable.
"""
import re
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as attachment_point_name_crud
from app.crud import iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud
from app.crud import part_type as part_type_crud
from app.crud import static_connection as static_connection_crud
from app.crud import unc_thread as unc_thread_crud
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.part_type import PartType
from app.models.unc_thread import UncSeries
from app.schemas.augmented_part import AugmentedPartSyncResult
ATTACHMENT_POINT_NAME = "Occular"
MM_PER_INCH = 25.4
# e.g.
# M8-1.25 - 8mm OD, 1.25 mm pitch
# M8 x 1.25
_ISO_THREAD_RE = re.compile(
r"^M\s*(?P<diameter>\d+(?:\.\d+)?)\s*[xX-]\s*(?P<pitch>\d+(?:\.\d+)?)(?P<extra>.*)$"
)
# e.g.
# 1.750-32 UNS
# 1.750-32
# 1.5625-40
#
# Leading digit / period, one or more digits, a dash, then
_UNC_THREAD_RE = re.compile(
r"^(?P<diameter>\d+(?:\.\d+)?(?:/\d+)?)\s*-\s*(?P<tpi>\d+)"
r"\s*(?P<series>UNEF|UNC|UNF|UNS|UN)?$",
re.IGNORECASE,
)
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
value = value.strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
"""Parse a TBXThreadAttribute string as an ISO metric or UNC/UNF thread spec."""
if m := _ISO_THREAD_RE.match(value):
return "iso", {
"diameter_mm": float(m.group("diameter")),
"pitch_mm": float(m.group("pitch")),
}
if m := _UNC_THREAD_RE.match(value):
# Source data often omits the series letters; UNS ("unified special") is the
# correct designation when the diameter/TPI pairing isn't a standard pitch.
series = UncSeries((m.group("series") or "UNS").upper())
return "unc", {
"diameter_fraction": m.group("diameter"),
"tpi": int(m.group("tpi")),
"series": series,
}
return None
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
apn = await attachment_point_name_crud.get_by_name(db, name)
if apn is None:
apn = await attachment_point_name_crud.create(db, name=name)
return apn
async def _add_attachment_point(db: AsyncSession, ap: AugmentedPart, ip: ImportedPart) -> None:
"""Infer and create an attachment point for a newly-synced AugmentedPart.
Preference order: TBXThreadAttribute (ISO/UNC thread) then ProductODmm/in
(StaticConnection). Falls through to the OD-based connection if the thread
attribute is present but unparseable.
"""
thread_value = (ip.TBXThreadAttribute or "").strip()
parsed = _parse_thread_attribute(thread_value) if thread_value else None
if parsed is not None:
kind, fields = parsed
apn = await _get_or_create_attachment_point_name(db, ATTACHMENT_POINT_NAME)
if kind == "iso":
depth_mm = _parse_float(ip.ProductMaxThreadDepthmm)
thread = await iso_thread_crud.get_by_values(db, depth_mm=depth_mm, **fields)
if thread is None:
thread = await iso_thread_crud.create(db, depth_mm=depth_mm, **fields)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
iso_thread_id=thread.id,
)
elif kind == "unc":
depth_in = _parse_float(ip.ProductMaxThreadDepthin)
thread = await unc_thread_crud.get_by_values(db, depth_in=depth_in, **fields)
if thread is None:
thread = await unc_thread_crud.create(db, depth_in=depth_in, **fields)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
unc_thread_id=thread.id,
)
else:
pass
return
od_mm = _parse_float(ip.ProductODmm)
od_in = _parse_float(ip.ProductODin)
if od_mm is None and od_in is None:
return
if od_mm is None:
od_mm = od_in * MM_PER_INCH
elif od_in is None:
od_in = od_mm / MM_PER_INCH
apn = await _get_or_create_attachment_point_name(db, ATTACHMENT_POINT_NAME)
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
if sc is None:
sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_in)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
static_connection_id=sc.id,
)
async def sync_one(
db: AsyncSession,
*,
inventory_id: str,
part_type_id: int,
sub_part_type_id: int | None,
) -> AugmentedPart:
"""Create an AugmentedPart for a single ImportedPart with manually supplied type."""
ip = await db.get(ImportedPart, inventory_id)
if ip is None:
raise ValueError(f"ImportedPart {inventory_id!r} not found")
description = (ip.Description or "").strip() or ip.InventoryID
colour_name = (ip.TBXColour or "").strip() or "Unknown"
colour = await part_colour_crud.get_by_name(db, colour_name)
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
manufacturer = await manufacturer_crud.get_by_name(db, "Armament")
if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
ap = AugmentedPart(
sku=ip.InventoryID,
description=description,
colour_id=colour.id,
manufacturer_id=manufacturer.id,
scope_model_id=None,
part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id,
)
db.add(ap)
await db.flush()
await db.execute(
augmented_part_imported_parts.insert().values(
augmented_part_id=ap.id,
imported_part_id=ip.InventoryID,
)
)
await _add_attachment_point(db, ap, ip)
await db.commit()
return ap
def _infer_part_type(description: str, part_types: list[PartType]) -> PartType | None:
"""Return the best-matching PartType or None.
Sorted by name length descending so more specific types (e.g. "Flip Cover")
are preferred over shorter ones (e.g. "Scope").
"""
desc_lower = description.lower()
for pt in sorted(part_types, key=lambda p: len(p.name), reverse=True):
if pt.name.lower() in desc_lower:
return pt
return None
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
# IDs referenced in existing associations
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct())
all_linked_ids: set[str] = {row[0] for row in result.all()}
# IDs that still exist in imported_parts
result = await db.execute(select(ImportedPart.InventoryID))
current_ids: set[str] = {row[0] for row in result.all()}
orphaned_import_ids = all_linked_ids - current_ids
deleted = 0
if orphaned_import_ids:
# Which AugmentedParts are affected?
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids)
)
)
orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
# Remove the stale association rows
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(orphaned_import_ids)
)
)
await db.flush()
# Delete AugmentedParts that now have no remaining associations
for ap_id in orphaned_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None:
await db.delete(ap)
deleted += 1
await db.commit()
# ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup
result = await db.execute(select(augmented_part_imported_parts.c.imported_part_id).distinct())
now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids
created = 0
skipped = 0
if not unlinked_ids:
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
result = await db.execute(
select(ImportedPart).where(ImportedPart.InventoryID.in_(unlinked_ids))
)
new_parts = list(result.scalars().all())
# Ensure "Armament" manufacturer exists
manufacturer = await manufacturer_crud.get_by_name(db, "Armament")
if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
part_types = await part_type_crud.list_all(db)
for ip in new_parts:
description = (ip.Description or "").strip() or ip.InventoryID
part_type = _infer_part_type(description, part_types)
if part_type is None:
skipped += 1
continue
colour_name = (ip.TBXColour or "").strip() or "Unknown"
colour = await part_colour_crud.get_by_name(db, colour_name)
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
ap = AugmentedPart(
sku=ip.InventoryID,
description=description,
colour_id=colour.id,
manufacturer_id=manufacturer.id,
scope_model_id=None,
part_type_id=part_type.id,
sub_part_type_id=None,
)
db.add(ap)
await db.flush()
await db.execute(
augmented_part_imported_parts.insert().values(
augmented_part_id=ap.id,
imported_part_id=ip.InventoryID,
)
)
await _add_attachment_point(db, ap, ip)
created += 1
await db.commit()
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
+201
View File
@@ -0,0 +1,201 @@
"""Detect "Possible" fits between AugmentedParts.
A part fits another part if:
- They are different PartTypes
- They have two attachment points that:
- Have different labels (AttachmentPointName)
- either:
- Are an ISO or UNC thread that exactly match in type and dimensions
- Are a Static Connection paired with a Clamp Connection where the
static dimension falls within the range of the clamp connection
Matches found here are recorded as Fit rows labeled Possible. Exact (manually
entered, or promoted from Possible) and None (explicitly marked impossible)
labels are user-asserted and are never created or removed by this algorithm.
"""
from collections import defaultdict
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import fit as fit_crud
from app.models.attachment_point import AttachmentPoint
from app.models.augmented_part import AugmentedPart
from app.models.clamp_connection import ClampConnection
from app.models.fit import FitLabel
from app.models.static_connection import StaticConnection
MM_PER_INCH = 25.4
def _iso_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
a, b = ap_a.iso_thread, ap_b.iso_thread
if a is None or b is None:
return False
return a.diameter_mm == b.diameter_mm and a.pitch_mm == b.pitch_mm
def _unc_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
a, b = ap_a.unc_thread, ap_b.unc_thread
if a is None or b is None:
return False
return (
a.diameter_fraction == b.diameter_fraction
and a.tpi == b.tpi
and a.series == b.series
)
def _clamp_range_mm(clamp: ClampConnection) -> tuple[float | None, float | None]:
if clamp.min_mm is not None or clamp.max_mm is not None:
return clamp.min_mm, clamp.max_mm
min_mm = clamp.min_in * MM_PER_INCH if clamp.min_in is not None else None
max_mm = clamp.max_in * MM_PER_INCH if clamp.max_in is not None else None
return min_mm, max_mm
def _static_in_clamp_range(static: StaticConnection, clamp: ClampConnection) -> bool:
min_mm, max_mm = _clamp_range_mm(clamp)
if min_mm is None and max_mm is None:
return False
if min_mm is not None and static.od_mm < min_mm:
return False
if max_mm is not None and static.od_mm > max_mm:
return False
return True
def _static_clamp_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
if ap_a.static_connection is not None and ap_b.clamp_connection is not None:
return _static_in_clamp_range(ap_a.static_connection, ap_b.clamp_connection)
if ap_b.static_connection is not None and ap_a.clamp_connection is not None:
return _static_in_clamp_range(ap_b.static_connection, ap_a.clamp_connection)
return False
def attachment_points_match(ap_a: AttachmentPoint, ap_b: AttachmentPoint) -> bool:
"""True if these two attachment points satisfy the fit-matching algorithm."""
if ap_a.attachment_point_name_id == ap_b.attachment_point_name_id:
return False
return (
_iso_match(ap_a, ap_b)
or _unc_match(ap_a, ap_b)
or _static_clamp_match(ap_a, ap_b)
)
def parts_could_fit(part_a: AugmentedPart, part_b: AugmentedPart) -> bool:
return part_a.id != part_b.id and part_a.part_type_id != part_b.part_type_id
@dataclass
class RecomputeResult:
created: int = 0
removed: int = 0
def __iadd__(self, other: "RecomputeResult") -> "RecomputeResult":
self.created += other.created
self.removed += other.removed
return self
async def _all_parts_by_id(db: AsyncSession) -> dict[int, AugmentedPart]:
result = await db.execute(select(AugmentedPart))
return {p.id: p for p in result.scalars().all()}
async def _attachment_points_by_part(
db: AsyncSession,
) -> dict[int, list[AttachmentPoint]]:
result = await db.execute(select(AttachmentPoint))
by_part: dict[int, list[AttachmentPoint]] = defaultdict(list)
for ap in result.scalars().all():
by_part[ap.augmented_part_id].append(ap)
return by_part
def _canonical_pair(a_id: int, b_id: int) -> tuple[int, int]:
return (a_id, b_id) if a_id < b_id else (b_id, a_id)
async def _reconcile(
db: AsyncSession,
*,
scoped_part_ids: set[int],
parts_by_id: dict[int, AugmentedPart],
points_by_part: dict[int, list[AttachmentPoint]],
) -> RecomputeResult:
"""Reconcile Possible fits for the given parts against every other part."""
candidate_pairs: set[tuple[int, int]] = set()
for part_id in scoped_part_ids:
part = parts_by_id.get(part_id)
if part is None:
continue
own_points = points_by_part.get(part_id, [])
if not own_points:
continue
for other_id, other in parts_by_id.items():
if not parts_could_fit(part, other):
continue
for own_ap in own_points:
for other_ap in points_by_part.get(other_id, []):
if attachment_points_match(own_ap, other_ap):
candidate_pairs.add(
_canonical_pair(own_ap.id, other_ap.id)
)
result = RecomputeResult()
existing_fits = []
for part_id in scoped_part_ids:
existing_fits.extend(await fit_crud.list_for_part(db, part_id))
existing_by_pair = {
(f.attachment_point_a_id, f.attachment_point_b_id): f for f in existing_fits
}
for pair, existing in existing_by_pair.items():
if existing.label == FitLabel.POSSIBLE and pair not in candidate_pairs:
await fit_crud.delete(db, existing)
result.removed += 1
for ap_a_id, ap_b_id in candidate_pairs:
if (ap_a_id, ap_b_id) in existing_by_pair:
continue
ap_a = await db.get(AttachmentPoint, ap_a_id)
ap_b = await db.get(AttachmentPoint, ap_b_id)
await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.POSSIBLE)
result.created += 1
return result
async def recompute_for_part(db: AsyncSession, augmented_part_id: int) -> RecomputeResult:
"""Reconcile Possible fits involving this part against the current algorithm.
Creates Possible fits for newly-matching attachment point pairs, and removes
previously-algorithmic Possible fits that no longer match. Exact/None labels
are user-asserted and are never touched.
"""
parts_by_id = await _all_parts_by_id(db)
if augmented_part_id not in parts_by_id:
return RecomputeResult()
points_by_part = await _attachment_points_by_part(db)
return await _reconcile(
db,
scoped_part_ids={augmented_part_id},
parts_by_id=parts_by_id,
points_by_part=points_by_part,
)
async def recompute_all(db: AsyncSession) -> RecomputeResult:
parts_by_id = await _all_parts_by_id(db)
points_by_part = await _attachment_points_by_part(db)
return await _reconcile(
db,
scoped_part_ids=set(parts_by_id),
parts_by_id=parts_by_id,
points_by_part=points_by_part,
)
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more