Compare commits

...
5 Commits
Author SHA1 Message Date
Ian Roddis 78efe32b69 Adding ACL Modificationf for existing roles 2026-06-24 22:01:25 -03:00
Ian Roddis 35e8a61b1d Adding ISOThread and UNC Thread 2026-06-24 21:58:15 -03:00
Ian Roddis 9285a021c0 adjust lock file 2026-06-24 21:37:14 -03:00
Ian Roddis a751e9b680 adding parts 2026-06-22 21:47:18 -03:00
Ian Roddis a3f686ab3b Baseline for fitguide 2026-06-22 21:32:22 -03:00
29 changed files with 916 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
# environment variable (which takes precedence over the .env file). # environment variable (which takes precedence over the .env file).
# --- General --- # --- General ---
APP_NAME="FastAPI Skeleton" APP_NAME="Armament FitGuide"
DEBUG=false DEBUG=false
# --- Security --- # --- Security ---
+23
View File
@@ -0,0 +1,23 @@
- Parts
- Type
- Scope
- Adapter Ring
- ARD
- Polarizer
- Flip Cover
- Attachment Point +
- Name
- Attachment
- One Of
- ISO Thread
- UNC Thread
- Outside Diameter
- static value (to be attached to)
- Clamp
- min
- max
- Manufacturers
- Models
- Colours
- Part Types
@@ -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")
+71
View File
@@ -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)
+3 -1
View File
@@ -1,8 +1,10 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(iso_threads.router, prefix="/iso-threads", tags=["iso-threads"])
api_router.include_router(unc_threads.router, prefix="/unc-threads", tags=["unc-threads"])
+72
View File
@@ -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)
+42
View File
@@ -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()
+48
View File
@@ -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()
+4
View File
@@ -1,15 +1,19 @@
"""Import all models so they register with ``Base.metadata``.""" """Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles 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.permission import Permission
from app.models.refresh_token import RefreshToken from app.models.refresh_token import RefreshToken
from app.models.role import Role from app.models.role import Role
from app.models.unc_thread import UncThread
from app.models.user import User from app.models.user import User
__all__ = [ __all__ = [
"IsoThread",
"Permission", "Permission",
"RefreshToken", "RefreshToken",
"Role", "Role",
"UncThread",
"User", "User",
"role_permissions", "role_permissions",
"user_roles", "user_roles",
+13
View File
@@ -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)
+24
View File
@@ -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)
+23
View File
@@ -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
+52
View File
@@ -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
+6
View File
@@ -8,6 +8,12 @@
{% if user.is_superuser or "roles:read" in user.permissions %} {% if user.is_superuser or "roles:read" in user.permissions %}
<a href="/admin/roles">Roles</a> <a href="/admin/roles">Roles</a>
{% endif %} {% endif %}
{% if user.is_superuser or "iso_threads:read" in user.permissions %}
<a href="/admin/iso-threads">ISO Threads</a>
{% endif %}
{% if user.is_superuser or "unc_threads:read" in user.permissions %}
<a href="/admin/unc-threads">UNC Threads</a>
{% endif %}
<a href="/docs" target="_blank">API docs</a> <a href="/docs" target="_blank">API docs</a>
</nav> </nav>
<form method="post" action="/logout" class="logout"> <form method="post" action="/logout" class="logout">
+17
View File
@@ -0,0 +1,17 @@
<tr id="iso-thread-{{ t.id }}">
<td>{{ t.id }}</td>
<td>{{ t.diameter_mm }}</td>
<td>{{ t.pitch_mm }}</td>
<td>{{ t.depth_mm if t.depth_mm is not none else "—" }}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/iso-threads/{{ t.id }}/edit"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/iso-threads/{{ t.id }}"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML"
hx-confirm="Delete ISO thread #{{ t.id }}?">Delete</button>
</td>
</tr>
@@ -0,0 +1,20 @@
<tr id="iso-thread-{{ t.id }}">
<td colspan="5">
<form hx-post="/admin/iso-threads/{{ t.id }}"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ t.id }}</span>
<label>Diameter (mm) <input type="number" name="diameter_mm" step="0.001" min="0" value="{{ t.diameter_mm }}" required></label>
<label>Pitch (mm) <input type="number" name="pitch_mm" step="0.001" min="0" value="{{ t.pitch_mm }}" required></label>
<label>Depth (mm) <input type="number" name="depth_mm" step="0.001" min="0" value="{{ t.depth_mm if t.depth_mm is not none else '' }}" placeholder="optional"></label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/iso-threads/{{ t.id }}/row"
hx-target="#iso-thread-{{ t.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
@@ -0,0 +1,16 @@
<div id="iso-thread-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Diameter (mm)</th><th>Pitch (mm)</th><th>Depth (mm)</th><th></th>
</tr>
</thead>
<tbody>
{% for t in threads %}
{% include "admin/_iso_thread_row.html" %}
{% else %}
<tr><td colspan="5" class="muted">No threads recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
+17
View File
@@ -0,0 +1,17 @@
<tr id="role-{{ r.id }}">
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/edit"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
+26
View File
@@ -0,0 +1,26 @@
<tr id="role-{{ r.id }}">
<td colspan="5">
<form hx-post="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ r.id }} · {{ r.name }}</span>
<fieldset class="roles-field">
<legend>Permissions</legend>
{% for perm in permissions %}
<label class="check">
<input type="checkbox" name="permission_ids" value="{{ perm.id }}"
{{ "checked" if perm in r.permissions }}> {{ perm.name }}
</label>
{% endfor %}
</fieldset>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/row"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
+1 -13
View File
@@ -5,19 +5,7 @@
</thead> </thead>
<tbody> <tbody>
{% for r in roles %} {% for r in roles %}
<tr id="role-{{ r.id }}"> {% include "admin/_role_row.html" %}
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
+18
View File
@@ -0,0 +1,18 @@
<tr id="unc-thread-{{ t.id }}">
<td>{{ t.id }}</td>
<td>{{ t.diameter_fraction }}"</td>
<td>{{ t.tpi }}</td>
<td>{{ t.series.value }}</td>
<td>{{ t.depth_in if t.depth_in is not none else "—" }}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/unc-threads/{{ t.id }}/edit"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/unc-threads/{{ t.id }}"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML"
hx-confirm="Delete UNC thread #{{ t.id }}?">Delete</button>
</td>
</tr>
@@ -0,0 +1,27 @@
<tr id="unc-thread-{{ t.id }}">
<td colspan="6">
<form hx-post="/admin/unc-threads/{{ t.id }}"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ t.id }}</span>
<label>Diameter <input type="text" name="diameter_fraction" value="{{ t.diameter_fraction }}" required></label>
<label>TPI <input type="number" name="tpi" min="1" step="1" value="{{ t.tpi }}" required></label>
<label>Series
<select name="series">
{% for s in series_options %}
<option value="{{ s.value }}" {{ "selected" if s == t.series }}>{{ s.value }}</option>
{% endfor %}
</select>
</label>
<label>Depth (in) <input type="number" name="depth_in" step="0.001" min="0" value="{{ t.depth_in if t.depth_in is not none else '' }}" placeholder="optional"></label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/unc-threads/{{ t.id }}/row"
hx-target="#unc-thread-{{ t.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
@@ -0,0 +1,16 @@
<div id="unc-thread-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Diameter</th><th>TPI</th><th>Series</th><th>Depth (in)</th><th></th>
</tr>
</thead>
<tbody>
{% for t in threads %}
{% include "admin/_unc_thread_row.html" %}
{% else %}
<tr><td colspan="6" class="muted">No threads recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}ISO Threads · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>ISO Threads</h1>
<div class="card">
<h2>Add thread</h2>
<form hx-post="/admin/iso-threads" hx-target="#iso-thread-list" hx-swap="outerHTML" class="form-grid">
<label>Diameter (mm) <input type="number" name="diameter_mm" step="0.001" min="0" required></label>
<label>Pitch (mm) <input type="number" name="pitch_mm" step="0.001" min="0" required></label>
<label>Depth (mm) <input type="number" name="depth_mm" step="0.001" min="0" placeholder="optional"></label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% include "admin/_iso_threads_table.html" %}
</main>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}UNC Threads · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>UNC Threads</h1>
<div class="card">
<h2>Add thread</h2>
<form hx-post="/admin/unc-threads" hx-target="#unc-thread-list" hx-swap="outerHTML" class="form-grid">
<label>Diameter <input type="text" name="diameter_fraction" placeholder='e.g. 1/4, 3/8, 1-1/2' required></label>
<label>TPI <input type="number" name="tpi" min="1" step="1" required></label>
<label>Series
<select name="series">
{% for s in series_options %}
<option value="{{ s.value }}">{{ s.value }}</option>
{% endfor %}
</select>
</label>
<label>Depth (in) <input type="number" name="depth_in" step="0.001" min="0" placeholder="optional"></label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% include "admin/_unc_threads_table.html" %}
</main>
{% endblock %}
+277 -2
View File
@@ -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 import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import iso_thread as iso_thread_crud
from app.crud import role as role_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.crud import user as user_crud
from app.deps import get_current_web_user from app.deps import get_current_web_user
from app.models.unc_thread import UncSeries
from app.models.user import User from app.models.user import User
from app.web.templates import templates from app.web.templates import templates
@@ -194,10 +197,58 @@ async def create_role(
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"admin/_roles_table.html", "admin/_roles_table.html",
{"roles": await role_crud.list_roles(db)}, {
"roles": await role_crud.list_roles(db),
"permissions": await role_crud.list_permissions(db),
},
) )
@router.get("/roles/{role_id}/edit", response_class=HTMLResponse)
async def edit_role_row(
request: Request,
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
return templates.TemplateResponse(
request,
"admin/_role_row_edit.html",
{"r": role, "permissions": await role_crud.list_permissions(db)},
)
@router.get("/roles/{role_id}/row", response_class=HTMLResponse)
async def role_row(
request: Request,
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:read")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
return templates.TemplateResponse(request, "admin/_role_row.html", {"r": role})
@router.post("/roles/{role_id}", response_class=HTMLResponse)
async def update_role(
request: Request,
role_id: int,
permission_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
await role_crud.update(db, role, {"permission_ids": permission_ids})
return templates.TemplateResponse(request, "admin/_role_row.html", {"r": role})
@router.delete("/roles/{role_id}") @router.delete("/roles/{role_id}")
async def delete_role( async def delete_role(
role_id: int, role_id: int,
@@ -208,3 +259,227 @@ async def delete_role(
if role is not None: if role is not None:
await role_crud.delete(db, role) await role_crud.delete(db, role)
return Response(status_code=status.HTTP_200_OK) 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)
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "fastapi-skeleton" name = "fitguide"
version = "0.1.0" version = "0.1.0"
description = "FastAPI + HTMX skeleton with async SQLAlchemy, RBAC, JWT auth, and an admin dashboard." description = "Fitguide for Armament"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+7 -1
View File
@@ -21,12 +21,18 @@ PERMISSIONS = [
("users:delete", "Delete users"), ("users:delete", "Delete users"),
("roles:read", "View roles and permissions"), ("roles:read", "View roles and permissions"),
("roles:write", "Create, update and delete roles"), ("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. # Default roles -> permission names. "admin" gets everything.
ROLES = { ROLES = {
"admin": [name for name, _ in PERMISSIONS], "admin": [name for name, _ in PERMISSIONS],
"user": ["users:read"], "user": ["users:read", "iso_threads:read", "unc_threads:read"],
} }
Generated
+1 -1
View File
@@ -280,7 +280,7 @@ wheels = [
] ]
[[package]] [[package]]
name = "fastapi-skeleton" name = "fitguide"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [