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
diff --git a/app/templates/admin/_iso_thread_row.html b/app/templates/admin/_iso_thread_row.html new file mode 100644 index 0000000..f7d2ffd --- /dev/null +++ b/app/templates/admin/_iso_thread_row.html @@ -0,0 +1,17 @@ + + {{ t.id }} + {{ t.diameter_mm }} + {{ t.pitch_mm }} + {{ t.depth_mm if t.depth_mm is not none else "—" }} + + + + + diff --git a/app/templates/admin/_iso_thread_row_edit.html b/app/templates/admin/_iso_thread_row_edit.html new file mode 100644 index 0000000..3a264c7 --- /dev/null +++ b/app/templates/admin/_iso_thread_row_edit.html @@ -0,0 +1,20 @@ + + + + #{{ t.id }} + + + +
+ + +
+
+ + diff --git a/app/templates/admin/_iso_threads_table.html b/app/templates/admin/_iso_threads_table.html new file mode 100644 index 0000000..c760784 --- /dev/null +++ b/app/templates/admin/_iso_threads_table.html @@ -0,0 +1,16 @@ +
+ + + + + + + + {% for t in threads %} + {% include "admin/_iso_thread_row.html" %} + {% else %} + + {% endfor %} + +
IDDiameter (mm)Pitch (mm)Depth (mm)
No threads recorded.
+
diff --git a/app/templates/admin/_unc_thread_row.html b/app/templates/admin/_unc_thread_row.html new file mode 100644 index 0000000..82cef3e --- /dev/null +++ b/app/templates/admin/_unc_thread_row.html @@ -0,0 +1,18 @@ + + {{ t.id }} + {{ t.diameter_fraction }}" + {{ t.tpi }} + {{ t.series.value }} + {{ t.depth_in if t.depth_in is not none else "—" }} + + + + + diff --git a/app/templates/admin/_unc_thread_row_edit.html b/app/templates/admin/_unc_thread_row_edit.html new file mode 100644 index 0000000..a79281f --- /dev/null +++ b/app/templates/admin/_unc_thread_row_edit.html @@ -0,0 +1,27 @@ + + +
+ #{{ t.id }} + + + + +
+ + +
+
+ + diff --git a/app/templates/admin/_unc_threads_table.html b/app/templates/admin/_unc_threads_table.html new file mode 100644 index 0000000..83da258 --- /dev/null +++ b/app/templates/admin/_unc_threads_table.html @@ -0,0 +1,16 @@ +
+ + + + + + + + {% for t in threads %} + {% include "admin/_unc_thread_row.html" %} + {% else %} + + {% endfor %} + +
IDDiameterTPISeriesDepth (in)
No threads recorded.
+
diff --git a/app/templates/admin/iso_threads.html b/app/templates/admin/iso_threads.html new file mode 100644 index 0000000..536ec53 --- /dev/null +++ b/app/templates/admin/iso_threads.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}ISO Threads · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

ISO Threads

+ +
+

Add thread

+
+ + + + +
+
+ + {% include "admin/_iso_threads_table.html" %} +
+{% endblock %} diff --git a/app/templates/admin/unc_threads.html b/app/templates/admin/unc_threads.html new file mode 100644 index 0000000..579ed6d --- /dev/null +++ b/app/templates/admin/unc_threads.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}UNC Threads · {{ app_name }}{% endblock %} +{% block body %} +{% include "_nav.html" %} +
+

UNC Threads

+ +
+

Add thread

+
+ + + + + +
+
+ + {% include "admin/_unc_threads_table.html" %} +
+{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 55572c3..ca69962 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1,13 +1,16 @@ -"""HTMX-driven admin dashboard for users and roles.""" +"""HTMX-driven admin dashboard for users, roles, and thread standards.""" from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response 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.crud import role as role_crud +from app.crud import unc_thread as unc_thread_crud from app.crud import user as user_crud from app.deps import get_current_web_user +from app.models.unc_thread import UncSeries from app.models.user import User from app.web.templates import templates @@ -208,3 +211,227 @@ async def delete_role( if role is not None: await role_crud.delete(db, role) return Response(status_code=status.HTTP_200_OK) + + +# --- ISO Threads ------------------------------------------------------------- + + +@router.get("/iso-threads", response_class=HTMLResponse) +async def iso_threads_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("iso_threads:read")), +): + return templates.TemplateResponse( + request, + "admin/iso_threads.html", + {"user": user, "threads": await iso_thread_crud.list_all(db)}, + ) + + +@router.post("/iso-threads", response_class=HTMLResponse) +async def create_iso_thread( + request: Request, + diameter_mm: float = Form(...), + pitch_mm: float = Form(...), + depth_mm: str = Form(""), + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("iso_threads:write")), +): + await iso_thread_crud.create( + db, + diameter_mm=diameter_mm, + pitch_mm=pitch_mm, + depth_mm=float(depth_mm) if depth_mm.strip() else None, + ) + return templates.TemplateResponse( + request, + "admin/_iso_threads_table.html", + {"threads": await iso_thread_crud.list_all(db)}, + ) + + +@router.get("/iso-threads/{thread_id}/edit", response_class=HTMLResponse) +async def edit_iso_thread_row( + request: Request, + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("iso_threads:write")), +): + thread = await iso_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="ISO thread not found") + return templates.TemplateResponse( + request, "admin/_iso_thread_row_edit.html", {"t": thread} + ) + + +@router.get("/iso-threads/{thread_id}/row", response_class=HTMLResponse) +async def iso_thread_row( + request: Request, + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("iso_threads:read")), +): + thread = await iso_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="ISO thread not found") + return templates.TemplateResponse( + request, "admin/_iso_thread_row.html", {"t": thread} + ) + + +@router.post("/iso-threads/{thread_id}", response_class=HTMLResponse) +async def update_iso_thread( + request: Request, + thread_id: int, + diameter_mm: float = Form(...), + pitch_mm: float = Form(...), + depth_mm: str = Form(""), + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("iso_threads:write")), +): + thread = await iso_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="ISO thread not found") + await iso_thread_crud.update( + db, + thread, + { + "diameter_mm": diameter_mm, + "pitch_mm": pitch_mm, + "depth_mm": float(depth_mm) if depth_mm.strip() else None, + }, + ) + return templates.TemplateResponse( + request, "admin/_iso_thread_row.html", {"t": thread} + ) + + +@router.delete("/iso-threads/{thread_id}") +async def delete_iso_thread( + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("iso_threads:delete")), +): + thread = await iso_thread_crud.get(db, thread_id) + if thread is not None: + await iso_thread_crud.delete(db, thread) + return Response(status_code=status.HTTP_200_OK) + + +# --- UNC Threads ------------------------------------------------------------- + + +@router.get("/unc-threads", response_class=HTMLResponse) +async def unc_threads_page( + request: Request, + db: AsyncSession = Depends(get_db), + user: User = Depends(web_require("unc_threads:read")), +): + return templates.TemplateResponse( + request, + "admin/unc_threads.html", + { + "user": user, + "threads": await unc_thread_crud.list_all(db), + "series_options": list(UncSeries), + }, + ) + + +@router.post("/unc-threads", response_class=HTMLResponse) +async def create_unc_thread( + request: Request, + diameter_fraction: str = Form(...), + tpi: int = Form(...), + series: UncSeries = Form(...), + depth_in: str = Form(""), + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("unc_threads:write")), +): + await unc_thread_crud.create( + db, + diameter_fraction=diameter_fraction, + tpi=tpi, + series=series, + depth_in=float(depth_in) if depth_in.strip() else None, + ) + return templates.TemplateResponse( + request, + "admin/_unc_threads_table.html", + {"threads": await unc_thread_crud.list_all(db)}, + ) + + +@router.get("/unc-threads/{thread_id}/edit", response_class=HTMLResponse) +async def edit_unc_thread_row( + request: Request, + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("unc_threads:write")), +): + thread = await unc_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="UNC thread not found") + return templates.TemplateResponse( + request, + "admin/_unc_thread_row_edit.html", + {"t": thread, "series_options": list(UncSeries)}, + ) + + +@router.get("/unc-threads/{thread_id}/row", response_class=HTMLResponse) +async def unc_thread_row( + request: Request, + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("unc_threads:read")), +): + thread = await unc_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="UNC thread not found") + return templates.TemplateResponse( + request, "admin/_unc_thread_row.html", {"t": thread} + ) + + +@router.post("/unc-threads/{thread_id}", response_class=HTMLResponse) +async def update_unc_thread( + request: Request, + thread_id: int, + diameter_fraction: str = Form(...), + tpi: int = Form(...), + series: UncSeries = Form(...), + depth_in: str = Form(""), + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("unc_threads:write")), +): + thread = await unc_thread_crud.get(db, thread_id) + if thread is None: + raise HTTPException(status_code=404, detail="UNC thread not found") + await unc_thread_crud.update( + db, + thread, + { + "diameter_fraction": diameter_fraction, + "tpi": tpi, + "series": series, + "depth_in": float(depth_in) if depth_in.strip() else None, + }, + ) + return templates.TemplateResponse( + request, "admin/_unc_thread_row.html", {"t": thread} + ) + + +@router.delete("/unc-threads/{thread_id}") +async def delete_unc_thread( + thread_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(web_require("unc_threads:delete")), +): + thread = await unc_thread_crud.get(db, thread_id) + if thread is not None: + await unc_thread_crud.delete(db, thread) + return Response(status_code=status.HTTP_200_OK) diff --git a/scripts/seed.py b/scripts/seed.py index 9bb4aa6..207dc1e 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -21,12 +21,18 @@ PERMISSIONS = [ ("users:delete", "Delete users"), ("roles:read", "View roles and permissions"), ("roles:write", "Create, update and delete roles"), + ("iso_threads:read", "View ISO threads"), + ("iso_threads:write", "Create and update ISO threads"), + ("iso_threads:delete", "Delete ISO threads"), + ("unc_threads:read", "View UNC threads"), + ("unc_threads:write", "Create and update UNC threads"), + ("unc_threads:delete", "Delete UNC threads"), ] # Default roles -> permission names. "admin" gets everything. ROLES = { "admin": [name for name, _ in PERMISSIONS], - "user": ["users:read"], + "user": ["users:read", "iso_threads:read", "unc_threads:read"], }