From 35e8a61b1dd5050644add7bafe6a00ef0a054a4e Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:58:15 -0300 Subject: [PATCH] Adding ISOThread and UNC Thread --- .../627d4460a94f_add_iso_threads_table.py | 42 ++++ app/api/iso_threads.py | 71 ++++++ app/api/router.py | 4 +- app/api/unc_threads.py | 72 ++++++ app/crud/iso_thread.py | 42 ++++ app/crud/unc_thread.py | 48 ++++ app/models/__init__.py | 4 + app/models/iso_thread.py | 13 + app/models/unc_thread.py | 24 ++ app/schemas/iso_thread.py | 23 ++ app/schemas/unc_thread.py | 52 ++++ app/templates/_nav.html | 6 + app/templates/admin/_iso_thread_row.html | 17 ++ app/templates/admin/_iso_thread_row_edit.html | 20 ++ app/templates/admin/_iso_threads_table.html | 16 ++ app/templates/admin/_unc_thread_row.html | 18 ++ app/templates/admin/_unc_thread_row_edit.html | 27 +++ app/templates/admin/_unc_threads_table.html | 16 ++ app/templates/admin/iso_threads.html | 20 ++ app/templates/admin/unc_threads.html | 27 +++ app/web/admin.py | 229 +++++++++++++++++- scripts/seed.py | 8 +- 22 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/627d4460a94f_add_iso_threads_table.py create mode 100644 app/api/iso_threads.py create mode 100644 app/api/unc_threads.py create mode 100644 app/crud/iso_thread.py create mode 100644 app/crud/unc_thread.py create mode 100644 app/models/iso_thread.py create mode 100644 app/models/unc_thread.py create mode 100644 app/schemas/iso_thread.py create mode 100644 app/schemas/unc_thread.py create mode 100644 app/templates/admin/_iso_thread_row.html create mode 100644 app/templates/admin/_iso_thread_row_edit.html create mode 100644 app/templates/admin/_iso_threads_table.html create mode 100644 app/templates/admin/_unc_thread_row.html create mode 100644 app/templates/admin/_unc_thread_row_edit.html create mode 100644 app/templates/admin/_unc_threads_table.html create mode 100644 app/templates/admin/iso_threads.html create mode 100644 app/templates/admin/unc_threads.html diff --git a/alembic/versions/627d4460a94f_add_iso_threads_table.py b/alembic/versions/627d4460a94f_add_iso_threads_table.py new file mode 100644 index 0000000..77c9bbe --- /dev/null +++ b/alembic/versions/627d4460a94f_add_iso_threads_table.py @@ -0,0 +1,42 @@ +"""add iso_threads and unc_threads tables + +Revision ID: 627d4460a94f +Revises: 0001_initial +Create Date: 2026-06-24 21:50:05.340235 +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "627d4460a94f" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "iso_threads", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("diameter_mm", sa.Float(), nullable=False), + sa.Column("pitch_mm", sa.Float(), nullable=False), + sa.Column("depth_mm", sa.Float(), nullable=True), + ) + + op.create_table( + "unc_threads", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("diameter_fraction", sa.String(length=16), nullable=False), + sa.Column("tpi", sa.Integer(), nullable=False), + sa.Column("series", sa.String(length=8), nullable=False), + sa.Column("depth_in", sa.Float(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("unc_threads") + op.drop_table("iso_threads") diff --git a/app/api/iso_threads.py b/app/api/iso_threads.py new file mode 100644 index 0000000..f0f1c6e --- /dev/null +++ b/app/api/iso_threads.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.crud import iso_thread as iso_thread_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.iso_thread import IsoThreadCreate, IsoThreadRead, IsoThreadUpdate + +router = APIRouter() + + +@router.get("", response_model=list[IsoThreadRead]) +async def list_iso_threads( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("iso_threads:read")), +): + return await iso_thread_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=IsoThreadRead, status_code=status.HTTP_201_CREATED) +async def create_iso_thread( + payload: IsoThreadCreate, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("iso_threads:write")), +): + return await iso_thread_crud.create( + db, + diameter_mm=payload.diameter_mm, + pitch_mm=payload.pitch_mm, + depth_mm=payload.depth_mm, + ) + + +@router.get("/{iso_thread_id}", response_model=IsoThreadRead) +async def get_iso_thread( + iso_thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("iso_threads:read")), +): + thread = await iso_thread_crud.get(db, iso_thread_id) + if thread is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ISO thread not found") + return thread + + +@router.patch("/{iso_thread_id}", response_model=IsoThreadRead) +async def update_iso_thread( + iso_thread_id: int, + payload: IsoThreadUpdate, + db: AsyncSession = Depends(get_db), + _: 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)) + + +@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")), +): + 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") + await iso_thread_crud.delete(db, thread) diff --git a/app/api/router.py b/app/api/router.py index 46a5d52..215b7cc 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,8 +1,10 @@ from fastapi import APIRouter -from app.api import auth, roles, users +from app.api import auth, iso_threads, roles, 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"]) diff --git a/app/api/unc_threads.py b/app/api/unc_threads.py new file mode 100644 index 0000000..f0d32ce --- /dev/null +++ b/app/api/unc_threads.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.crud import unc_thread as unc_thread_crud +from app.deps import require_permission +from app.models.user import User +from app.schemas.unc_thread import UncThreadCreate, UncThreadRead, UncThreadUpdate + +router = APIRouter() + + +@router.get("", response_model=list[UncThreadRead]) +async def list_unc_threads( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("unc_threads:read")), +): + return await unc_thread_crud.list_all(db, skip=skip, limit=limit) + + +@router.post("", response_model=UncThreadRead, status_code=status.HTTP_201_CREATED) +async def create_unc_thread( + payload: UncThreadCreate, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("unc_threads:write")), +): + return await unc_thread_crud.create( + db, + diameter_fraction=payload.diameter_fraction, + tpi=payload.tpi, + series=payload.series, + depth_in=payload.depth_in, + ) + + +@router.get("/{unc_thread_id}", response_model=UncThreadRead) +async def get_unc_thread( + unc_thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(require_permission("unc_threads:read")), +): + thread = await unc_thread_crud.get(db, unc_thread_id) + if thread is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UNC thread not found") + return thread + + +@router.patch("/{unc_thread_id}", response_model=UncThreadRead) +async def update_unc_thread( + unc_thread_id: int, + payload: UncThreadUpdate, + db: AsyncSession = Depends(get_db), + _: 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)) + + +@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")), +): + 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") + await unc_thread_crud.delete(db, thread) diff --git a/app/crud/iso_thread.py b/app/crud/iso_thread.py new file mode 100644 index 0000000..cfd19f4 --- /dev/null +++ b/app/crud/iso_thread.py @@ -0,0 +1,42 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.iso_thread import IsoThread + + +async def get(db: AsyncSession, iso_thread_id: int) -> IsoThread | None: + return await db.get(IsoThread, iso_thread_id) + + +async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[IsoThread]: + result = await db.execute( + select(IsoThread).order_by(IsoThread.id).offset(skip).limit(limit) + ) + return list(result.scalars().all()) + + +async def create( + db: AsyncSession, + *, + diameter_mm: float, + pitch_mm: float, + depth_mm: float | None = None, +) -> IsoThread: + thread = IsoThread(diameter_mm=diameter_mm, pitch_mm=pitch_mm, depth_mm=depth_mm) + db.add(thread) + await db.commit() + await db.refresh(thread) + return thread + + +async def update(db: AsyncSession, thread: IsoThread, data: dict) -> IsoThread: + for field, value in data.items(): + setattr(thread, field, value) + await db.commit() + await db.refresh(thread) + return thread + + +async def delete(db: AsyncSession, thread: IsoThread) -> None: + await db.delete(thread) + await db.commit() diff --git a/app/crud/unc_thread.py b/app/crud/unc_thread.py new file mode 100644 index 0000000..ffe9ef2 --- /dev/null +++ b/app/crud/unc_thread.py @@ -0,0 +1,48 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.unc_thread import UncSeries, UncThread + + +async def get(db: AsyncSession, unc_thread_id: int) -> UncThread | None: + return await db.get(UncThread, unc_thread_id) + + +async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[UncThread]: + result = await db.execute( + select(UncThread).order_by(UncThread.id).offset(skip).limit(limit) + ) + return list(result.scalars().all()) + + +async def create( + db: AsyncSession, + *, + diameter_fraction: str, + tpi: int, + series: UncSeries, + depth_in: float | None = None, +) -> UncThread: + thread = UncThread( + diameter_fraction=diameter_fraction, + tpi=tpi, + series=series, + depth_in=depth_in, + ) + db.add(thread) + await db.commit() + await db.refresh(thread) + return thread + + +async def update(db: AsyncSession, thread: UncThread, data: dict) -> UncThread: + for field, value in data.items(): + setattr(thread, field, value) + await db.commit() + await db.refresh(thread) + return thread + + +async def delete(db: AsyncSession, thread: UncThread) -> None: + await db.delete(thread) + await db.commit() diff --git a/app/models/__init__.py b/app/models/__init__.py index 035eee7..f28f762 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,15 +1,19 @@ """Import all models so they register with ``Base.metadata``.""" from app.models.associations import role_permissions, user_roles +from app.models.iso_thread import IsoThread from app.models.permission import Permission 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__ = [ + "IsoThread", "Permission", "RefreshToken", "Role", + "UncThread", "User", "role_permissions", "user_roles", diff --git a/app/models/iso_thread.py b/app/models/iso_thread.py new file mode 100644 index 0000000..d002fde --- /dev/null +++ b/app/models/iso_thread.py @@ -0,0 +1,13 @@ +from sqlalchemy import Float +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class IsoThread(Base): + __tablename__ = "iso_threads" + + id: Mapped[int] = mapped_column(primary_key=True) + diameter_mm: Mapped[float] = mapped_column(Float, nullable=False) + pitch_mm: Mapped[float] = mapped_column(Float, nullable=False) + depth_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) diff --git a/app/models/unc_thread.py b/app/models/unc_thread.py new file mode 100644 index 0000000..8591d90 --- /dev/null +++ b/app/models/unc_thread.py @@ -0,0 +1,24 @@ +import enum + +from sqlalchemy import Enum, Float, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class UncSeries(str, enum.Enum): + UNC = "UNC" + UNF = "UNF" + UNEF = "UNEF" + UN = "UN" + UNS = "UNS" + + +class UncThread(Base): + __tablename__ = "unc_threads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + diameter_fraction: Mapped[str] = mapped_column(String(16), nullable=False) + tpi: Mapped[int] = mapped_column(Integer, nullable=False) + series: Mapped[UncSeries] = mapped_column(Enum(UncSeries), nullable=False) + depth_in: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) diff --git a/app/schemas/iso_thread.py b/app/schemas/iso_thread.py new file mode 100644 index 0000000..c9c7a6d --- /dev/null +++ b/app/schemas/iso_thread.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class IsoThreadBase(BaseModel): + diameter_mm: float = Field(..., gt=0, description="Nominal diameter in mm") + pitch_mm: float = Field(..., gt=0, description="Thread pitch in mm") + depth_mm: float | None = Field(None, gt=0, description="Thread depth in mm") + + +class IsoThreadCreate(IsoThreadBase): + pass + + +class IsoThreadUpdate(BaseModel): + diameter_mm: float | None = Field(None, gt=0) + pitch_mm: float | None = Field(None, gt=0) + depth_mm: float | None = Field(None, gt=0) + + +class IsoThreadRead(IsoThreadBase): + model_config = ConfigDict(from_attributes=True) + + id: int diff --git a/app/schemas/unc_thread.py b/app/schemas/unc_thread.py new file mode 100644 index 0000000..23d08c0 --- /dev/null +++ b/app/schemas/unc_thread.py @@ -0,0 +1,52 @@ +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.models.unc_thread import UncSeries + +_FRACTION_RE = re.compile(r"^\d+(/\d+)?(-\d+/\d+)?$") + + +class UncThreadBase(BaseModel): + diameter_fraction: str = Field( + ..., + description='Nominal diameter as a fractional inch string, e.g. "1/4", "3/8", "1-1/2"', + ) + tpi: int = Field(..., gt=0, description="Threads per inch") + series: UncSeries + depth_in: float | None = Field(None, gt=0, description="Thread depth in inches") + + @field_validator("diameter_fraction") + @classmethod + def validate_fraction(cls, v: str) -> str: + if not _FRACTION_RE.match(v): + raise ValueError( + 'diameter_fraction must be a fractional inch string, e.g. "1/4", "3/8", "1-1/2"' + ) + return v + + +class UncThreadCreate(UncThreadBase): + pass + + +class UncThreadUpdate(BaseModel): + diameter_fraction: str | None = None + tpi: int | None = Field(None, gt=0) + series: UncSeries | None = None + depth_in: float | None = Field(None, gt=0) + + @field_validator("diameter_fraction") + @classmethod + def validate_fraction(cls, v: str | None) -> str | None: + if v is not None and not _FRACTION_RE.match(v): + raise ValueError( + 'diameter_fraction must be a fractional inch string, e.g. "1/4", "3/8", "1-1/2"' + ) + return v + + +class UncThreadRead(UncThreadBase): + model_config = ConfigDict(from_attributes=True) + + id: int diff --git a/app/templates/_nav.html b/app/templates/_nav.html index f210122..3779ef9 100644 --- a/app/templates/_nav.html +++ b/app/templates/_nav.html @@ -8,6 +8,12 @@ {% if user.is_superuser or "roles:read" in user.permissions %} Roles {% endif %} + {% if user.is_superuser or "iso_threads:read" in user.permissions %} + ISO Threads + {% endif %} + {% if user.is_superuser or "unc_threads:read" in user.permissions %} + UNC Threads + {% endif %} API docs