Adding archived flag to augmented parts, fixing migrations so that creation from scratch is successful

This commit is contained in:
Ian Roddis committed 2026-08-17 22:16:53 -03:00
1 parent 740dfc97c7
commit 413e9e8381
27 files changed
+695 -126

No files matched your search

+5 -2
View File
@@ -24,10 +24,13 @@ router = APIRouter()
async def list_augmented_parts(
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("augmented_parts:read")),
):
return await augmented_part_crud.list_all(db, skip=skip, limit=limit)
return await augmented_part_crud.list_all(
db, skip=skip, limit=limit, include_archived=include_archived
)
@router.post("", response_model=AugmentedPartRead, status_code=status.HTTP_201_CREATED)
@@ -72,7 +75,7 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.deleted} deleted, "
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
+16 -1
View File
@@ -13,6 +13,7 @@ from app.schemas.imported_part import (
ImportedPartRead,
SyncResult,
)
from app.services import augmented_part_sync
from app.services import odata as odata_service
router = APIRouter()
@@ -128,7 +129,9 @@ async def delete_imported_part(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Imported part not found"
)
await imported_part_crud.delete(db, part)
archived = await augmented_part_sync.delete_imported_part_and_archive_orphans(
db, part
)
await audit_crud.log(
db,
actor=actor,
@@ -137,3 +140,15 @@ async def delete_imported_part(
resource_id=None,
description=audit_crud.describe_delete("ImportedPart", 0, inventory_id),
)
if archived:
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Archived {archived} augmented part(s) left with no remaining "
f"imported parts after deleting {inventory_id!r}"
),
)
+23 -7
View File
@@ -58,14 +58,15 @@ async def get_by_sku(db: AsyncSession, sku: str) -> AugmentedPart | None:
async def list_all(
db: AsyncSession, skip: int = 0, limit: int = 100
db: AsyncSession,
skip: int = 0,
limit: int = 100,
include_archived: bool = False,
) -> list[AugmentedPart]:
result = await db.execute(
select(AugmentedPart)
.order_by(AugmentedPart.description)
.offset(skip)
.limit(limit)
)
q = select(AugmentedPart).order_by(AugmentedPart.description)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -85,6 +86,7 @@ async def list_filtered(
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
sort: str = "description",
direction: str = "asc",
skip: int = 0,
@@ -106,6 +108,8 @@ async def list_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -119,6 +123,7 @@ async def count_filtered(
scope_model_id: int | None = None,
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
) -> int:
from sqlalchemy import func
@@ -138,6 +143,8 @@ async def count_filtered(
q = q.where(AugmentedPart.part_type_id == part_type_id)
if sub_part_type_id is not None:
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
result = await db.execute(q)
return result.scalar_one()
@@ -251,6 +258,15 @@ async def get_unlinked_imported_parts(db: AsyncSession) -> list[ImportedPart]:
return list(result.scalars().all())
async def set_archived(
db: AsyncSession, ap: AugmentedPart, archived: bool
) -> AugmentedPart:
ap.archived = archived
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()
-5
View File
@@ -105,11 +105,6 @@ async def count_filtered(db: AsyncSession, description: str = "") -> int:
return result.scalar_one()
async def delete(db: AsyncSession, part: ImportedPart) -> None:
await db.delete(part)
await db.commit()
async def upsert_bulk(db: AsyncSession, records: list[dict]) -> tuple[int, int]:
"""Insert-or-update records keyed on InventoryID. Returns (created, updated)."""
if not records:
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -36,6 +36,10 @@ class AugmentedPart(Base):
nullable=True,
default=None,
)
# Set instead of deleting the row when the last backing ImportedPart is
# removed, so fit/attachment-point history is preserved. Hidden from list
# views by default.
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# linked imported parts — not eagerly loaded; used by sync service only
imported_parts: Mapped[list[ImportedPart]] = relationship(
+3 -1
View File
@@ -53,11 +53,12 @@ class AugmentedPartUpdate(BaseModel):
scope_model_id: int | None = None
part_type_id: int | None = None
sub_part_type_id: int | None = None
archived: bool | None = None
class AugmentedPartSyncResult(BaseModel):
created: int
deleted: int
archived: int
skipped: int
@@ -65,6 +66,7 @@ class AugmentedPartRead(AugmentedPartBase):
model_config = ConfigDict(from_attributes=True)
id: int
archived: bool
colour: PartColourRead
manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None
+94 -40
View File
@@ -2,11 +2,12 @@
Algorithm
---------
Phase 1 – prune stale entries
Phase 1 – archive stale entries
• Find association rows whose imported_part_id no longer exists.
• Delete those rows.
• Delete any AugmentedPart that ends up with zero remaining associations
(i.e. it was created purely from now-deleted ImportedParts).
• Archive (rather than delete) any AugmentedPart that ends up with zero
remaining associations (i.e. it was created purely from now-deleted
ImportedParts), preserving its fit/attachment-point history.
Phase 2 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
@@ -33,12 +34,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import attachment_point as attachment_point_crud
from app.crud import attachment_point_name as attachment_point_name_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import iso_thread as iso_thread_crud
from app.crud import manufacturer as manufacturer_crud
from app.crud import part_colour as part_colour_crud
from app.crud import part_type as part_type_crud
from app.crud import static_connection as static_connection_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.crud import unc_thread as unc_thread_crud
from app.models.associations import augmented_part_imported_parts
from app.models.augmented_part import AugmentedPart
@@ -268,6 +269,91 @@ def _infer_part_type(description: str, part_types: list[PartType]) -> PartType |
return None
async def _archive_orphans(db: AsyncSession, candidate_ap_ids: set[int]) -> int:
"""Archive any of `candidate_ap_ids` left with zero linked ImportedParts.
Idempotent: already-archived parts and parts that still have a link are
left untouched. Does not commit — the caller commits as part of its own
transaction.
"""
archived = 0
for ap_id in candidate_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None and not ap.archived:
ap.archived = True
archived += 1
return archived
async def _detach_and_archive_orphans(
db: AsyncSession, imported_part_ids: set[str]
) -> int:
"""Detach `imported_part_ids` from any AugmentedPart association and archive
AugmentedParts left with zero remaining ImportedPart links as a result.
Deletes the association rows directly rather than relying on the
ImportedPart row's ON DELETE CASCADE, since SQLite (this app's dev/test
database) doesn't enforce FK actions by default. Safe to call whether or
not the ImportedPart row(s) still exist. Does not commit — the caller
commits as part of its own transaction.
"""
if not imported_part_ids:
return 0
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(imported_part_ids)
)
)
await db.flush()
return await _archive_orphans(db, candidate_ap_ids)
async def delete_imported_part_and_archive_orphans(
db: AsyncSession, imported_part: ImportedPart
) -> int:
"""Delete `imported_part` and archive any AugmentedPart left with no
remaining ImportedPart links as a result.
Captures affected AugmentedPart ids before deleting so this is correct
regardless of whether the DB enforces the association table's ON DELETE
CASCADE. Returns the number of AugmentedParts newly archived.
"""
inventory_id = imported_part.InventoryID
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
candidate_ap_ids: set[int] = {row[0] for row in result.all()}
await db.delete(imported_part)
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id == inventory_id
)
)
await db.flush()
archived = await _archive_orphans(db, candidate_ap_ids)
await db.commit()
return archived
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
@@ -282,42 +368,10 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
current_ids: set[str] = {row[0] for row in result.all()}
orphaned_import_ids = all_linked_ids - current_ids
deleted = 0
archived = 0
if orphaned_import_ids:
# Which AugmentedParts are affected?
result = await db.execute(
select(augmented_part_imported_parts.c.augmented_part_id).where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
orphaned_ap_ids: set[int] = {row[0] for row in result.all()}
# Remove the stale association rows
await db.execute(
augmented_part_imported_parts.delete().where(
augmented_part_imported_parts.c.imported_part_id.in_(
orphaned_import_ids
)
)
)
await db.flush()
# Delete AugmentedParts that now have no remaining associations
for ap_id in orphaned_ap_ids:
result = await db.execute(
select(func.count())
.select_from(augmented_part_imported_parts)
.where(augmented_part_imported_parts.c.augmented_part_id == ap_id)
)
if result.scalar() == 0:
ap = await db.get(AugmentedPart, ap_id)
if ap is not None:
await db.delete(ap)
deleted += 1
archived = await _detach_and_archive_orphans(db, orphaned_import_ids)
await db.commit()
# ------------------------------------------------------------------ phase 2
@@ -334,7 +388,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, deleted=deleted, skipped=skipped
created=created, archived=archived, skipped=skipped
)
result = await db.execute(
@@ -385,4 +439,4 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
await db.commit()
return AugmentedPartSyncResult(created=created, deleted=deleted, skipped=skipped)
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
+2
View File
@@ -113,6 +113,8 @@ async def build(
other = other_parts.get(other_id)
if other is None or other.part_type_id == scope_type.id:
continue # scope-to-scope fits have no column
if other.archived:
continue # archived parts don't show up as compatible accessories
by_type = cell_map[scope_id].setdefault(other.part_type_id, {})
existing = by_type.get(other.id)
label = fit.label.value
@@ -7,11 +7,24 @@
<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>{% if ap.archived %}<span class="tag">Archived</span>{% else %}—{% endif %}</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>
{% if ap.archived %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ ap.id }}/unarchive"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML">Unarchive</button>
{% else %}
<button class="btn btn-ghost"
hx-post="/admin/augmented-parts/{{ ap.id }}/archive"
hx-target="#augmented-part-{{ ap.id }}"
hx-swap="outerHTML"
hx-confirm="Archive {{ ap.sku or ap.description }}?">Archive</button>
{% endif %}
<button class="btn btn-danger"
hx-delete="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
@@ -1,5 +1,5 @@
<tr id="augmented-part-{{ ap.id }}">
<td colspan="9">
<td colspan="10">
<form id="augmented-part-edit-form-{{ ap.id }}"
hx-post="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
@@ -43,6 +43,7 @@
<option value="{{ spt.id }}" {{ "selected" if filter_sub_part_type_id and filter_sub_part_type_id|int == spt.id }}>{{ spt.name }}</option>
{% endfor %}
</select>
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
</form>
</div>
@@ -12,6 +12,7 @@
{{ sort_th("scope_model", "Model", sort_headers) }}
{{ sort_th("part_type", "Type", sort_headers) }}
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
<th>Archived</th>
<th></th>
</tr>
</thead>
@@ -19,7 +20,7 @@
{% for ap in augmented_parts %}
{% include "admin/_augmented_part_row.html" %}
{% else %}
<tr><td colspan="9" class="muted">No augmented parts recorded.</td></tr>
<tr><td colspan="10" class="muted">No augmented parts recorded.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -10,6 +10,7 @@
<p class="muted">
<span class="tag">{{ ap.part_type.name }}</span>
{% if ap.sub_part_type %}<span class="tag">{{ ap.sub_part_type.name }}</span>{% endif %}
{% if ap.archived %}<span class="tag">Archived</span>{% endif %}
{% if ap.description %}{{ ap.description }}{% endif %}
</p>
+1 -1
View File
@@ -56,7 +56,7 @@
<div class="card">
<h2>Sync with Imported Parts</h2>
<p class="muted">Creates Augmented Part entries for new Imported Parts, and removes entries for deleted Imported Parts.</p>
<p class="muted">Creates Augmented Part entries for new Imported Parts, and archives entries for deleted Imported Parts.</p>
<div class="row-actions">
<form hx-post="/admin/augmented-parts/sync" hx-target="#augmented-part-list" hx-swap="outerHTML">
<button type="submit" class="btn btn-primary">Sync now</button>
+74 -6
View File
@@ -1660,6 +1660,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
),
"sort": "description",
"dir": "asc",
"include_archived": False,
}
@@ -1668,17 +1669,24 @@ async def augmented_parts_page(
request: Request,
sort: str = "description",
dir: str = "asc",
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("augmented_parts:read")),
):
ctx = await _augmented_part_form_ctx(db)
total = await augmented_part_crud.count_filtered(db)
total = await augmented_part_crud.count_filtered(
db, include_archived=include_archived
)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
db, sort=sort, direction=dir, limit=_PAGE_SIZE
db,
sort=sort,
direction=dir,
include_archived=include_archived,
limit=_PAGE_SIZE,
)
ctx["pagination"] = _paginate(
"/admin/augmented-parts/filter",
{"sort": sort, "dir": dir},
{"sort": sort, "dir": dir, "include_archived": include_archived},
1,
_PAGE_SIZE,
total,
@@ -1686,7 +1694,7 @@ async def augmented_parts_page(
)
ctx["sort_headers"] = _sort_headers(
"/admin/augmented-parts/filter",
{},
{"include_archived": include_archived},
_AUGMENTED_PARTS_SORT_COLUMNS,
sort,
dir,
@@ -1694,6 +1702,7 @@ async def augmented_parts_page(
)
ctx["sort"] = sort
ctx["dir"] = dir
ctx["include_archived"] = include_archived
ctx["user"] = user
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@@ -1764,7 +1773,7 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.deleted} deleted, "
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
@@ -1772,7 +1781,7 @@ async def sync_augmented_parts(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.deleted} deleted"
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"
if result.skipped
@@ -1846,6 +1855,7 @@ async def filter_augmented_parts(
scope_model_id: str = "",
part_type_id: str = "",
sub_part_type_id: str = "",
include_archived: bool = False,
sort: str = "description",
dir: str = "asc",
page: int = 1,
@@ -1866,6 +1876,7 @@ async def filter_augmented_parts(
scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
)
filter_params = {
"description": description,
@@ -1874,6 +1885,7 @@ async def filter_augmented_parts(
"scope_model_id": scope_model_id,
"part_type_id": part_type_id,
"sub_part_type_id": sub_part_type_id,
"include_archived": include_archived,
}
pagination = _paginate(
"/admin/augmented-parts/filter",
@@ -1891,6 +1903,7 @@ async def filter_augmented_parts(
scope_model_id=scope_model_id_int,
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
sort=sort,
direction=dir,
skip=pagination["skip"],
@@ -1913,6 +1926,7 @@ async def filter_augmented_parts(
"sort_headers": sort_headers,
"sort": sort,
"dir": dir,
"include_archived": include_archived,
},
)
@@ -2210,6 +2224,60 @@ async def delete_augmented_part(
return Response(status_code=status.HTTP_200_OK)
async def _set_augmented_part_archived(
request: Request,
augmented_part_id: int,
archived: bool,
db: AsyncSession,
actor: User,
):
ap = await augmented_part_crud.get(db, augmented_part_id)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
ap = await augmented_part_crud.set_archived(db, ap, archived)
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, {"archived": archived}
),
)
return templates.TemplateResponse(
request, "admin/_augmented_part_row.html", {"ap": ap}
)
@router.post(
"/augmented-parts/{augmented_part_id}/archive", response_class=HTMLResponse
)
async def archive_augmented_part(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
return await _set_augmented_part_archived(
request, augmented_part_id, True, db, actor
)
@router.post(
"/augmented-parts/{augmented_part_id}/unarchive", response_class=HTMLResponse
)
async def unarchive_augmented_part(
request: Request,
augmented_part_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
return await _set_augmented_part_archived(
request, augmented_part_id, False, db, actor
)
def _can_write_attachment_points(user: User) -> bool:
return user.is_superuser or "attachment_points:write" in user.permissions