Adding augmented parts

This commit is contained in:
Ian Roddis committed 2026-07-02 12:38:59 -03:00
1 parent 2e7002b73b
commit 00fb43c8ed
14 files changed
+590 -2

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
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,
AugmentedPartUpdate,
)
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.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),
)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api import audit_log, auth, imported_parts, iso_threads, manufacturers, part_colours, part_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users
from app.api import audit_log, augmented_parts, auth, imported_parts, iso_threads, manufacturers, part_colours, part_types, roles, scope_models, static_connections, sub_part_types, unc_threads, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -15,4 +15,5 @@ api_router.include_router(part_types.router, prefix="/part-types", tags=["part-t
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(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"])
+63
View File
@@ -0,0 +1,63 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.augmented_part import AugmentedPart
_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 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 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 delete(db: AsyncSession, ap: AugmentedPart) -> None:
await db.delete(ap)
await db.commit()
+2
View File
@@ -2,6 +2,7 @@
from app.models.associations import role_permissions, user_roles
from app.models.audit_log import AuditLog
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.imported_part_config import ImportedPartConfig
from app.models.iso_thread import IsoThread
@@ -19,6 +20,7 @@ from app.models.user import User
__all__ = [
"AuditLog",
"AugmentedPart",
"ImportedPart",
"ImportedPartConfig",
"IsoThread",
+38
View File
@@ -0,0 +1,38 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
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
)
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")
+42
View File
@@ -0,0 +1,42 @@
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 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
+3
View File
@@ -29,6 +29,9 @@
{% if user.is_superuser or "imported_parts:read" in user.permissions %}
<a href="/admin/imported-parts">Imported Parts</a>
{% endif %}
{% if user.is_superuser or "augmented_parts:read" in user.permissions %}
<a href="/admin/augmented-parts">Augmented Parts</a>
{% endif %}
{% if user.is_superuser or "part_colours:read" in user.permissions %}
<a href="/admin/part-colours">Part Colours</a>
{% endif %}
@@ -0,0 +1,21 @@
<tr id="augmented-part-{{ ap.id }}">
<td>{{ ap.id }}</td>
<td>{{ ap.sku or '—' }}</td>
<td>{{ ap.description | truncate(60) }}</td>
<td>{{ ap.colour.name }}</td>
<td>{{ ap.manufacturer.name }}</td>
<td>{{ ap.scope_model.name if ap.scope_model else '—' }}</td>
<td>{{ ap.part_type.name }}</td>
<td>{{ ap.sub_part_type.name if ap.sub_part_type else '—' }}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML"
hx-confirm="Delete {{ ap.sku or ap.description }}?">Delete</button>
</td>
</tr>
@@ -0,0 +1,56 @@
<tr id="augmented-part-{{ ap.id }}">
<td colspan="9">
<form hx-post="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ ap.id }}</span>
<label>SKU <input type="text" name="sku" value="{{ ap.sku or '' }}" placeholder="optional"></label>
<label>Description <input type="text" name="description" value="{{ ap.description }}" required></label>
<label>Colour
<select name="colour_id" required>
{% for pc in part_colours %}
<option value="{{ pc.id }}" {{ "selected" if pc.id == ap.colour_id }}>{{ pc.name }}</option>
{% endfor %}
</select>
</label>
<label>Manufacturer
<select name="manufacturer_id" required>
{% for m in manufacturers %}
<option value="{{ m.id }}" {{ "selected" if m.id == ap.manufacturer_id }}>{{ m.name }}</option>
{% endfor %}
</select>
</label>
<label>Model
<select name="scope_model_id">
<option value="" {{ "selected" if ap.scope_model_id is none }}>— none —</option>
{% for sm in scope_models %}
<option value="{{ sm.id }}" {{ "selected" if sm.id == ap.scope_model_id }}>{{ sm.name }} ({{ sm.manufacturer.name }})</option>
{% endfor %}
</select>
</label>
<label>Type
<select name="part_type_id" required>
{% for pt in part_types %}
<option value="{{ pt.id }}" {{ "selected" if pt.id == ap.part_type_id }}>{{ pt.name }}</option>
{% endfor %}
</select>
</label>
<label>Sub Type
<select name="sub_part_type_id">
<option value="" {{ "selected" if ap.sub_part_type_id is none }}>— none —</option>
{% for spt in sub_part_types %}
<option value="{{ spt.id }}" {{ "selected" if spt.id == ap.sub_part_type_id }}>{{ spt.name }} ({{ spt.part_type.name }})</option>
{% endfor %}
</select>
</label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ ap.id }}/row"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
@@ -0,0 +1,24 @@
<div id="augmented-part-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>Description</th>
<th>Colour</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Type</th>
<th>Sub Type</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ap in augmented_parts %}
{% include "admin/_augmented_part_row.html" %}
{% else %}
<tr><td colspan="9" class="muted">No augmented parts recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Augmented Parts · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Augmented Parts</h1>
<div class="card">
<h2>Add part</h2>
<form hx-post="/admin/augmented-parts" hx-target="#augmented-part-list" hx-swap="outerHTML" class="form-grid">
<label>SKU <input type="text" name="sku" placeholder="optional"></label>
<label>Description <input type="text" name="description" required></label>
<label>Colour
<select name="colour_id" required>
<option value="">— select —</option>
{% for pc in part_colours %}
<option value="{{ pc.id }}">{{ pc.name }}</option>
{% endfor %}
</select>
</label>
<label>Manufacturer
<select name="manufacturer_id" required>
<option value="">— select —</option>
{% for m in manufacturers %}
<option value="{{ m.id }}">{{ m.name }}</option>
{% endfor %}
</select>
</label>
<label>Model
<select name="scope_model_id">
<option value="">— none —</option>
{% for sm in scope_models %}
<option value="{{ sm.id }}">{{ sm.name }} ({{ sm.manufacturer.name }})</option>
{% endfor %}
</select>
</label>
<label>Type
<select name="part_type_id" required>
<option value="">— select —</option>
{% for pt in part_types %}
<option value="{{ pt.id }}">{{ pt.name }}</option>
{% endfor %}
</select>
</label>
<label>Sub Type
<select name="sub_part_type_id">
<option value="">— none —</option>
{% for spt in sub_part_types %}
<option value="{{ spt.id }}">{{ spt.name }} ({{ spt.part_type.name }})</option>
{% endfor %}
</select>
</label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
{% include "admin/_augmented_parts_table.html" %}
</main>
{% endblock %}
+148
View File
@@ -6,6 +6,7 @@ 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.crud import imported_part as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_crud
@@ -1119,6 +1120,153 @@ async def refresh_imported_parts(
)
# --- Augmented Parts ---------------------------------------------------------
async def _augmented_part_form_ctx(db: AsyncSession) -> dict:
return {
"part_colours": await part_colour_crud.list_all(db),
"manufacturers": await manufacturer_crud.list_all(db),
"scope_models": await scope_model_crud.list_all(db),
"part_types": await part_type_crud.list_all(db),
"sub_part_types": await sub_part_type_crud.list_all(db),
}
@router.get("/augmented-parts", response_class=HTMLResponse)
async def augmented_parts_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read")),
):
ctx = await _augmented_part_form_ctx(db)
ctx["user"] = user
ctx["augmented_parts"] = await augmented_part_crud.list_all(db)
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@router.post("/augmented-parts", response_class=HTMLResponse)
async def create_augmented_part(
request: Request,
sku: str = Form(""),
description: str = Form(...),
colour_id: int = Form(...),
manufacturer_id: int = Form(...),
scope_model_id: str = Form(""),
part_type_id: int = Form(...),
sub_part_type_id: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
created = await augmented_part_crud.create(
db,
sku=sku.strip() or None,
description=description,
colour_id=colour_id,
manufacturer_id=manufacturer_id,
scope_model_id=int(scope_model_id) if scope_model_id else None,
part_type_id=part_type_id,
sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None,
)
await audit_crud.log(
db, actor=actor, action="create", resource_type="AugmentedPart",
resource_id=created.id,
description=audit_crud.describe_create(
"AugmentedPart", created.id, created.sku or created.description
),
)
return templates.TemplateResponse(
request,
"admin/_augmented_parts_table.html",
{"augmented_parts": await augmented_part_crud.list_all(db)},
)
@router.get("/augmented-parts/{augmented_part_id}/edit", response_class=HTMLResponse)
async def edit_augmented_part_row(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("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")
ctx = await _augmented_part_form_ctx(db)
ctx["ap"] = ap
return templates.TemplateResponse(request, "admin/_augmented_part_row_edit.html", ctx)
@router.get("/augmented-parts/{augmented_part_id}/row", response_class=HTMLResponse)
async def augmented_part_row(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("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 templates.TemplateResponse(
request, "admin/_augmented_part_row.html", {"ap": ap}
)
@router.post("/augmented-parts/{augmented_part_id}", response_class=HTMLResponse)
async def update_augmented_part(
request: Request,
augmented_part_id: int,
sku: str = Form(""),
description: str = Form(...),
colour_id: int = Form(...),
manufacturer_id: int = Form(...),
scope_model_id: str = Form(""),
part_type_id: int = Form(...),
sub_part_type_id: str = Form(""),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("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 = {
"sku": sku.strip() or None,
"description": description,
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"scope_model_id": int(scope_model_id) if scope_model_id else None,
"part_type_id": part_type_id,
"sub_part_type_id": int(sub_part_type_id) if sub_part_type_id else None,
}
await augmented_part_crud.update(db, ap, changes)
await audit_crud.log(
db, actor=actor, action="update", resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_update("AugmentedPart", augmented_part_id, changes),
)
return templates.TemplateResponse(
request, "admin/_augmented_part_row.html", {"ap": ap}
)
@router.delete("/augmented-parts/{augmented_part_id}")
async def delete_augmented_part(
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:delete")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is not None:
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),
)
return Response(status_code=status.HTTP_200_OK)
# --- Part Colours ------------------------------------------------------------