Compare commits

...
10 Commits
41 changed files with 1546 additions and 61 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
[ ] Drop 'id' from UI tables, add 'date added / updated'
[x] Adapter Rings should get two attachment points
[ ] Add 'on_website' boolean to augmented part
[x] API: allow updating parts by ID or by SKU
[ ] Add `preferred_part` to augmented parts, used to order matches (e.g. double tab vs single tab)
[ ] Adapter Rings
[ ] Should be married to a specific flip cover (a, b), not (a)x(b) cartesian product
@@ -0,0 +1,37 @@
"""manufacturers_url_nullable
Revision ID: 0fec09fb6ae6
Revises: 9a1c2f6e0b3d
Create Date: 2026-09-04 23:05:18.990806
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0fec09fb6ae6"
down_revision: str | None = "9a1c2f6e0b3d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
batch_op.alter_column(
"url",
existing_type=sa.String(),
nullable=True,
)
def downgrade() -> None:
op.execute("UPDATE manufacturers SET url = '' WHERE url IS NULL")
with op.batch_alter_table("manufacturers", schema=None) as batch_op:
batch_op.alter_column(
"url",
existing_type=sa.String(),
nullable=False,
)
@@ -0,0 +1,26 @@
"""augmented_parts_sku_unique
Revision ID: 1c2d3e4f5a6b
Revises: 0fec09fb6ae6
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1c2d3e4f5a6b"
down_revision: str | None = "0fec09fb6ae6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.create_index("ix_augmented_parts_sku", ["sku"], unique=True)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_index("ix_augmented_parts_sku")
@@ -0,0 +1,28 @@
"""imported_parts_uac_diameter_range
Revision ID: 2b3c4d5e6f7a
Revises: 1c2d3e4f5a6b
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2b3c4d5e6f7a"
down_revision: str | None = "1c2d3e4f5a6b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
batch_op.add_column(sa.Column("UACDiameterRangemm", sa.String(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("imported_parts", schema=None) as batch_op:
batch_op.drop_column("UACDiameterRangemm")
@@ -0,0 +1,30 @@
"""augmented_parts_uac_diameter_range
Revision ID: 3c4d5e6f7a8b
Revises: 2b3c4d5e6f7a
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "3c4d5e6f7a8b"
down_revision: str | None = "2b3c4d5e6f7a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.add_column(
sa.Column("uac_diameter_range_mm", sa.String(), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_column("uac_diameter_range_mm")
@@ -0,0 +1,41 @@
"""augmented_parts_on_website_preferred
Revision ID: 4d5e6f7a8b9c
Revises: 3c4d5e6f7a8b
Create Date: 2026-09-21 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4d5e6f7a8b9c"
down_revision: str | None = "3c4d5e6f7a8b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"on_website", sa.Boolean(), nullable=False, server_default=sa.false()
)
)
batch_op.add_column(
sa.Column(
"preferred_part",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("augmented_parts", schema=None) as batch_op:
batch_op.drop_column("preferred_part")
batch_op.drop_column("on_website")
+10
View File
@@ -33,6 +33,10 @@ async def create_attachment_point_name(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("attachment_point_names:write")),
):
if await apn_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=409, detail="Attachment point name already exists"
)
apn = await apn_crud.create(db, name=payload.name)
await audit_crud.log(
db,
@@ -68,6 +72,12 @@ async def update_attachment_point_name(
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await apn_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != apn.id:
raise HTTPException(
status_code=409, detail="Attachment point name already exists"
)
apn = await apn_crud.update(db, apn, changes)
await audit_crud.log(
db,
+28 -12
View File
@@ -20,6 +20,15 @@ from app.services import augmented_part_sync
router = APIRouter()
async def _get_augmented_part_by_identifier(db: AsyncSession, identifier: str):
"""Resolve an AugmentedPart by numeric id, falling back to sku."""
if identifier.isdigit():
ap = await augmented_part_crud.get(db, int(identifier))
if ap is not None:
return ap
return await augmented_part_crud.get_by_sku(db, identifier)
@router.get("", response_model=list[AugmentedPartRead])
async def list_augmented_parts(
skip: int = 0,
@@ -39,6 +48,8 @@ async def create_augmented_part(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:write")),
):
if payload.sku and await augmented_part_crud.get_by_sku(db, payload.sku):
raise HTTPException(status_code=409, detail="SKU already in use")
ap = await augmented_part_crud.create(
db,
sku=payload.sku,
@@ -48,6 +59,8 @@ async def create_augmented_part(
scope_model_id=payload.scope_model_id,
part_type_id=payload.part_type_id,
sub_part_type_id=payload.sub_part_type_id,
on_website=payload.on_website,
preferred_part=payload.preferred_part,
)
await audit_crud.log(
db,
@@ -75,8 +88,8 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"{result.skipped} skipped (no type match)"
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, {result.skipped} skipped (no type match)"
),
)
return result
@@ -94,17 +107,21 @@ async def get_augmented_part(
return ap
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
@router.patch("/{identifier}", response_model=AugmentedPartRead)
async def update_augmented_part(
augmented_part_id: int,
identifier: str,
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)
ap = await _get_augmented_part_by_identifier(db, identifier)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
changes = payload.model_dump(exclude_unset=True)
if changes.get("sku"):
existing = await augmented_part_crud.get_by_sku(db, changes["sku"])
if existing is not None and existing.id != ap.id:
raise HTTPException(status_code=409, detail="SKU already in use")
ap = await augmented_part_crud.update(db, ap, changes)
await audit_crud.log(
db,
@@ -117,15 +134,16 @@ async def update_augmented_part(
return ap
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_augmented_part(
augmented_part_id: int,
identifier: str,
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("augmented_parts:delete")),
):
ap = await augmented_part_crud.get(db, augmented_part_id)
ap = await _get_augmented_part_by_identifier(db, identifier)
if ap is None:
raise HTTPException(status_code=404, detail="Augmented part not found")
resolved_id = ap.id
label = ap.sku or ap.description
await augmented_part_crud.delete(db, ap)
await audit_crud.log(
@@ -133,10 +151,8 @@ async def delete_augmented_part(
actor=actor,
action="delete",
resource_type="AugmentedPart",
resource_id=augmented_part_id,
description=audit_crud.describe_delete(
"AugmentedPart", augmented_part_id, label
),
resource_id=resolved_id,
description=audit_crud.describe_delete("AugmentedPart", resolved_id, label),
)
+6
View File
@@ -27,6 +27,8 @@ async def create_part_colour(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_colours:write")),
):
if await part_colour_crud.get_by_name(db, payload.name):
raise HTTPException(status_code=409, detail="Part colour already exists")
pc = await part_colour_crud.create(db, name=payload.name)
await audit_crud.log(
db,
@@ -62,6 +64,10 @@ async def update_part_colour(
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await part_colour_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != pc.id:
raise HTTPException(status_code=409, detail="Part colour already exists")
pc = await part_colour_crud.update(db, pc, changes)
await audit_crud.log(
db,
+10
View File
@@ -27,6 +27,10 @@ async def create_part_type(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("part_types:write")),
):
if await part_type_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
)
pt = await part_type_crud.create(db, name=payload.name)
await audit_crud.log(
db,
@@ -66,6 +70,12 @@ async def update_part_type(
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
)
changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await part_type_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != pt.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
)
pt = await part_type_crud.update(db, pt, changes)
await audit_crud.log(
db,
+6
View File
@@ -81,6 +81,12 @@ async def update_role(
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
)
changes = payload.model_dump(exclude_unset=True)
if "name" in changes:
existing = await role_crud.get_by_name(db, changes["name"])
if existing is not None and existing.id != role.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
role = await role_crud.update(db, role, changes)
await audit_crud.log(
db,
+8
View File
@@ -33,6 +33,8 @@ async def create_static_connection(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("static_connections:write")),
):
if await static_connection_crud.get_by_od_mm(db, payload.od_mm):
raise HTTPException(status_code=409, detail="Static connection already exists")
sc = await static_connection_crud.create(
db, od_mm=payload.od_mm, od_in=payload.od_in
)
@@ -72,6 +74,12 @@ async def update_static_connection(
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
changes = payload.model_dump(exclude_unset=True)
if "od_mm" in changes:
existing = await static_connection_crud.get_by_od_mm(db, changes["od_mm"])
if existing is not None and existing.id != sc.id:
raise HTTPException(
status_code=409, detail="Static connection already exists"
)
sc = await static_connection_crud.update(db, sc, changes)
await audit_crud.log(
db,
+12
View File
@@ -31,6 +31,10 @@ async def create_sub_part_type(
db: AsyncSession = Depends(get_db),
actor: User = Depends(require_permission("sub_part_types:write")),
):
if await sub_part_type_crud.get_by_name_and_part_type(
db, payload.name, payload.part_type_id
):
raise HTTPException(status_code=409, detail="Sub part type already exists")
spt = await sub_part_type_crud.create(
db, name=payload.name, part_type_id=payload.part_type_id
)
@@ -72,6 +76,14 @@ async def update_sub_part_type(
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
)
changes = payload.model_dump(exclude_unset=True)
if "name" in changes or "part_type_id" in changes:
name = changes.get("name", spt.name)
part_type_id = changes.get("part_type_id", spt.part_type_id)
existing = await sub_part_type_crud.get_by_name_and_part_type(
db, name, part_type_id
)
if existing is not None and existing.id != spt.id:
raise HTTPException(status_code=409, detail="Sub part type already exists")
spt = await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log(
db,
+6
View File
@@ -82,6 +82,12 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
changes = payload.model_dump(exclude_unset=True)
if "email" in changes:
existing = await user_crud.get_by_email(db, changes["email"])
if existing is not None and existing.id != target.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
target = await user_crud.update(db, target, changes)
await audit_crud.log(
db,
+18 -1
View File
@@ -10,7 +10,14 @@ from app.models.part_type import PartType
from app.models.scope_model import ScopeModel
from app.models.sub_part_type import SubPartType
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
_RELATIONSHIPS = [
"colour",
"manufacturer",
"scope_model",
"part_type",
"sub_part_type",
"notes",
]
# Sortable columns for the admin list, keyed by the query-string "sort" value.
# Columns backed by a related table's name require an outer join (the FK may be
@@ -87,6 +94,7 @@ async def list_filtered(
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
on_website: bool = False,
sort: str = "description",
direction: str = "asc",
skip: int = 0,
@@ -110,6 +118,8 @@ async def list_filtered(
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
if on_website:
q = q.where(AugmentedPart.on_website.is_(True))
q = _apply_sort(q, sort, direction)
result = await db.execute(q.offset(skip).limit(limit))
return list(result.scalars().all())
@@ -124,6 +134,7 @@ async def count_filtered(
part_type_id: int | None = None,
sub_part_type_id: int | None = None,
include_archived: bool = False,
on_website: bool = False,
) -> int:
from sqlalchemy import func
@@ -145,6 +156,8 @@ async def count_filtered(
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
if not include_archived:
q = q.where(AugmentedPart.archived.is_(False))
if on_website:
q = q.where(AugmentedPart.on_website.is_(True))
result = await db.execute(q)
return result.scalar_one()
@@ -220,6 +233,8 @@ async def create(
scope_model_id: int | None,
part_type_id: int,
sub_part_type_id: int | None,
on_website: bool = False,
preferred_part: bool = False,
) -> AugmentedPart:
ap = AugmentedPart(
sku=sku,
@@ -229,6 +244,8 @@ async def create(
scope_model_id=scope_model_id,
part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id,
on_website=on_website,
preferred_part=preferred_part,
)
db.add(ap)
await db.commit()
+11
View File
@@ -22,6 +22,17 @@ async def list_all(
return list(result.scalars().all())
async def get_by_values(
db: AsyncSession, *, min_mm: float | None, max_mm: float | None
) -> ClampConnection | None:
result = await db.execute(
select(ClampConnection).where(
ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm
)
)
return result.scalars().first()
async def create(
db: AsyncSession,
*,
+2
View File
@@ -13,6 +13,7 @@ _FIELDS = frozenset(
"ProductODmm",
"ProductODin",
"TBXThreadAttribute",
"UACDiameterRangemm",
}
)
@@ -26,6 +27,7 @@ SORT_COLUMNS = {
"ProductODmm": ImportedPart.ProductODmm,
"ProductODin": ImportedPart.ProductODin,
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
"UACDiameterRangemm": ImportedPart.UACDiameterRangemm,
}
+1 -1
View File
@@ -26,7 +26,7 @@ async def create(
db: AsyncSession,
*,
name: str,
url: str,
url: str | None = None,
logo_url: str | None = None,
) -> Manufacturer:
m = Manufacturer(name=name, url=url, logo_url=logo_url)
+10 -1
View File
@@ -17,7 +17,9 @@ 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)
sku: Mapped[str | None] = mapped_column(
String, nullable=True, unique=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
@@ -36,10 +38,17 @@ class AugmentedPart(Base):
nullable=True,
default=None,
)
# "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync
# service. Read-only — never set outside app/services/augmented_part_sync.py.
uac_diameter_range_mm: Mapped[str | None] = mapped_column(
String, 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)
on_website: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
preferred_part: 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(
+1
View File
@@ -19,3 +19,4 @@ class ImportedPart(Base):
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
UACDiameterRangemm: Mapped[str | None] = mapped_column(nullable=True, default=None)
+1 -1
View File
@@ -8,5 +8,5 @@ class Manufacturer(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(nullable=False)
url: Mapped[str] = mapped_column(nullable=True)
url: Mapped[str | None] = mapped_column(nullable=True)
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
+6
View File
@@ -39,6 +39,8 @@ class AugmentedPartBase(BaseModel):
scope_model_id: int | None = None
part_type_id: int
sub_part_type_id: int | None = None
on_website: bool = False
preferred_part: bool = False
class AugmentedPartCreate(AugmentedPartBase):
@@ -54,10 +56,13 @@ class AugmentedPartUpdate(BaseModel):
part_type_id: int | None = None
sub_part_type_id: int | None = None
archived: bool | None = None
on_website: bool | None = None
preferred_part: bool | None = None
class AugmentedPartSyncResult(BaseModel):
created: int
updated: int
archived: int
skipped: int
@@ -67,6 +72,7 @@ class AugmentedPartRead(AugmentedPartBase):
id: int
archived: bool
uac_diameter_range_mm: str | None
colour: PartColourRead
manufacturer: ManufacturerRead
scope_model: ScopeModelRead | None
+1
View File
@@ -12,6 +12,7 @@ class ImportedPartRead(BaseModel):
ProductODmm: str | None
ProductODin: str | None
TBXThreadAttribute: str | None
UACDiameterRangemm: str | None
class ImportedPartConfigRead(BaseModel):
+11 -1
View File
@@ -1,4 +1,10 @@
from pydantic import BaseModel, ConfigDict, HttpUrl
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
def _blank_to_none(value: str | None) -> str | None:
if isinstance(value, str) and not value.strip():
return None
return value
class ManufacturerBase(BaseModel):
@@ -6,6 +12,8 @@ class ManufacturerBase(BaseModel):
url: HttpUrl | None = None
logo_url: HttpUrl | None = None
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
class ManufacturerCreate(ManufacturerBase):
pass
@@ -16,6 +24,8 @@ class ManufacturerUpdate(BaseModel):
url: HttpUrl | None = None
logo_url: HttpUrl | None = None
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
class ManufacturerRead(ManufacturerBase):
model_config = ConfigDict(from_attributes=True)
+154 -4
View File
@@ -9,7 +9,24 @@ Phase 1 – archive stale entries
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
Phase 2 – refresh entries for already-linked ImportedParts
• For each ImportedPart already linked to an AugmentedPart (from a prior
sync run), refresh the fields sync itself owns — description, colour,
and uac_diameter_range_mm (see field mapping below) — to match the
ImportedPart's current values.
• Never touches "extra"/admin-owned AugmentedPart fields: part_type_id,
sub_part_type_id, scope_model_id, manufacturer_id, sku, archived. Those
are only ever set once, at creation (phase 3) or via the admin API/UI.
• Adds an "Ocular" and/or "Objective" attachment point if the part doesn't
already have one of that name and the current ImportedPart data now
supports deriving one. Never deletes or modifies an existing attachment
point, even if the source data that produced it has since changed.
• If an AugmentedPart has more than one backing ImportedPart, the one with
the lexicographically smallest InventoryID is used as the source (this
doesn't happen via any current creation path, but the data model and
phase 1's archiving logic both allow for it).
Phase 3 – create entries for new ImportedParts
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
• Description ← ImportedPart.Description (fallback: InventoryID)
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
@@ -25,6 +42,12 @@ Phase 2 – create entries for new ImportedParts
unit is missing is derived via mm/in conversion) if the
thread attribute is absent or unparseable
Skipped entirely if none of those fields are usable.
UAC diameter range ← ImportedPart.UACDiameterRangemm, an 8-digit string with
no separators (2 leading digits + 2 decimal digits for the
minimum, then the same for the maximum) reformatted as
AugmentedPart.uac_diameter_range_mm = "##.##-##.##". When
present, also adds a second attachment point named
"Objective" with a ClampConnection spanning that range.
"""
import re
@@ -34,6 +57,7 @@ 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 augmented_part as augmented_part_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
@@ -49,6 +73,7 @@ from app.models.unc_thread import UncSeries
from app.schemas.augmented_part import AugmentedPartSyncResult
ATTACHMENT_POINT_NAME = "Ocular"
UAC_ATTACHMENT_POINT_NAME = "Objective"
MM_PER_INCH = 25.4
# e.g.
@@ -118,6 +143,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
return None
def _parse_uac_diameter_range(value: str | None) -> tuple[str, float, float] | None:
"""Parse an 8-digit UACDiameterRangemm value into ("##.##-##.##", min, max).
The source field is 8 digits with no separators: the first 4 are the
minimum diameter (2 whole + 2 decimal), the last 4 the maximum, e.g.
"03505500" -> ("03.50-55.00", 3.5, 55.0).
"""
if not value:
return None
digits = "".join(re.findall(r"\d", value))
if len(digits) != 8:
return None
min_str = f"{digits[0:2]}.{digits[2:4]}"
max_str = f"{digits[4:6]}.{digits[6:8]}"
return f"{min_str}-{max_str}", float(min_str), float(max_str)
async def _add_uac_clamp_attachment_point(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> None:
"""Add a second, "Objective"-named clamp attachment point derived from
ImportedPart.UACDiameterRangemm, if that field is populated."""
parsed = _parse_uac_diameter_range(ip.UACDiameterRangemm)
if parsed is None:
return
_, min_mm, max_mm = parsed
min_in = min_mm / MM_PER_INCH
max_in = max_mm / MM_PER_INCH
apn = await _get_or_create_attachment_point_name(db, UAC_ATTACHMENT_POINT_NAME)
clamp = await clamp_connection_crud.get_by_values(db, min_mm=min_mm, max_mm=max_mm)
if clamp is None:
clamp = await clamp_connection_crud.create(
db, min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in
)
await attachment_point_crud.create(
db,
augmented_part_id=ap.id,
attachment_point_name_id=apn.id,
clamp_connection_id=clamp.id,
)
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
apn = await attachment_point_name_crud.get_by_name(db, name)
if apn is None:
@@ -233,6 +301,8 @@ async def sync_one(
if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart(
sku=ip.InventoryID,
description=description,
@@ -241,6 +311,7 @@ async def sync_one(
scope_model_id=None,
part_type_id=part_type_id,
sub_part_type_id=sub_part_type_id,
uac_diameter_range_mm=uac_range[0] if uac_range else None,
)
db.add(ap)
await db.flush()
@@ -252,6 +323,7 @@ async def sync_one(
)
)
await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
await db.commit()
return ap
@@ -354,6 +426,52 @@ async def delete_imported_part_and_archive_orphans(
return archived
async def _refresh_augmented_part(
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
) -> bool:
"""Refresh the AugmentedPart fields sync owns (description, colour, UAC
diameter range) from the current state of `ip`, and add an "Ocular"
and/or "Objective" attachment point if the part doesn't already have one
and the current data now supports deriving one.
Never touches "extra" AugmentedPart fields (part_type_id, sub_part_type_id,
scope_model_id, manufacturer_id, sku, archived) and never deletes or
replaces an attachment point that already exists. Returns True if
anything changed.
"""
description = (ip.Description or "").strip() or ip.InventoryID
colour_name = (ip.TBXColour or "").strip() or "Unknown"
colour = await part_colour_crud.get_by_name(db, colour_name)
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
uac_value = uac_range[0] if uac_range else None
changes = {}
if ap.description != description:
changes["description"] = description
if ap.colour_id != colour.id:
changes["colour_id"] = colour.id
if ap.uac_diameter_range_mm != uac_value:
changes["uac_diameter_range_mm"] = uac_value
if changes:
await augmented_part_crud.update(db, ap, changes)
existing_points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
existing_names = {p.attachment_point_name.name for p in existing_points}
points_before = len(existing_points)
if ATTACHMENT_POINT_NAME not in existing_names:
await _add_attachment_point(db, ap, ip)
if UAC_ATTACHMENT_POINT_NAME not in existing_names:
await _add_uac_clamp_attachment_point(db, ap, ip)
points_after = await attachment_point_crud.list_for_augmented_part(db, ap.id)
return bool(changes) or len(points_after) > points_before
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 1
@@ -376,19 +494,45 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
# ------------------------------------------------------------------ phase 2
# Re-query linked IDs after phase 1 cleanup
# Re-query linked IDs after phase 1 cleanup — these are the ImportedParts
# already linked to an AugmentedPart from a prior run (not created below).
result = await db.execute(
select(augmented_part_imported_parts.c.imported_part_id).distinct()
)
now_linked_ids: set[str] = {row[0] for row in result.all()}
unlinked_ids = current_ids - now_linked_ids
updated = 0
if now_linked_ids:
result = await db.execute(
select(
augmented_part_imported_parts.c.augmented_part_id,
augmented_part_imported_parts.c.imported_part_id,
).where(
augmented_part_imported_parts.c.imported_part_id.in_(now_linked_ids)
)
)
ip_ids_by_ap_id: dict[int, list[str]] = {}
for ap_id, ip_id in result.all():
ip_ids_by_ap_id.setdefault(ap_id, []).append(ip_id)
for ap_id, ip_ids in ip_ids_by_ap_id.items():
ap = await db.get(AugmentedPart, ap_id)
ip = await db.get(ImportedPart, min(ip_ids))
if ap is None or ip is None:
continue
if await _refresh_augmented_part(db, ap, ip):
updated += 1
# ------------------------------------------------------------------ phase 3
created = 0
skipped = 0
if not unlinked_ids:
return AugmentedPartSyncResult(
created=created, archived=archived, skipped=skipped
created=created, updated=updated, archived=archived, skipped=skipped
)
result = await db.execute(
@@ -416,6 +560,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
if colour is None:
colour = await part_colour_crud.create(db, name=colour_name)
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
ap = AugmentedPart(
sku=ip.InventoryID,
description=description,
@@ -424,6 +570,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
scope_model_id=None,
part_type_id=part_type.id,
sub_part_type_id=None,
uac_diameter_range_mm=uac_range[0] if uac_range else None,
)
db.add(ap)
await db.flush()
@@ -435,8 +582,11 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
)
)
await _add_attachment_point(db, ap, ip)
await _add_uac_clamp_attachment_point(db, ap, ip)
created += 1
await db.commit()
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
return AugmentedPartSyncResult(
created=created, updated=updated, archived=archived, skipped=skipped
)
+24 -10
View File
@@ -145,11 +145,15 @@ def parse_workbook(data: bytes) -> list[dict]:
wb.close()
async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float):
async def _static_ap(
db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float
):
"""Create an attachment point carrying a StaticConnection for ``od_mm``."""
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
if sc is None:
sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH)
sc = await static_connection_crud.create(
db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH
)
apn = await apn_crud.get_by_name(db, label)
if apn is None:
apn = await apn_crud.create(db, name=label)
@@ -161,7 +165,9 @@ async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od
)
async def _thread_ap(db: AsyncSession, *, augmented_part_id: int, label: str, spec: str):
async def _thread_ap(
db: AsyncSession, *, augmented_part_id: int, label: str, spec: str
):
"""Create an attachment point for a parsed ISO/UNC thread, or None."""
parsed = _parse_thread_attribute(spec)
if parsed is None:
@@ -268,14 +274,18 @@ async def _link_fit(
if target_aps:
target_ap = target_aps[0]
else:
target_ap = await _mirrored_ap(db, augmented_part_id=target.id, scope_ap=scope_ap)
target_ap = await _mirrored_ap(
db, augmented_part_id=target.id, scope_ap=scope_ap
)
messages.append(
f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
"created one matching the scope's attachment point"
)
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
if existing is None:
await fit_crud.create(db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE)
await fit_crud.create(
db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE
)
async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult:
@@ -300,9 +310,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
model = _text(row.get("model"))
magnification_min = _num(row.get("magnification_min"))
magnification_max = _num(row.get("magnification_max"))
objective_lens_diameter_mm = _num(row.get("objective_lens_diameter_mm")) or _num(
row.get("objective_lens")
)
objective_lens_diameter_mm = _num(
row.get("objective_lens_diameter_mm")
) or _num(row.get("objective_lens"))
# Construct the model number
default_product_number = model
@@ -333,7 +343,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name)
if manufacturer is None:
manufacturer = await manufacturer_crud.create(db, name=manufacturer_name, url="")
manufacturer = await manufacturer_crud.create(
db, name=manufacturer_name, url=""
)
model_name = _text(row.get("model"))
url = _text(row.get("url"))
@@ -395,7 +407,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
# Rebuild attachment points from scratch so re-imports stay idempotent
# (cascades delete any fits that referenced the old points).
for old_ap in await attachment_point_crud.list_for_augmented_part(db, part.id):
for old_ap in await attachment_point_crud.list_for_augmented_part(
db, part.id
):
await attachment_point_crud.delete(db, old_ap)
ocular_ap = None
@@ -8,6 +8,8 @@
<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>{% if ap.on_website %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
<td>{% if ap.preferred_part %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
@@ -1,5 +1,5 @@
<tr id="augmented-part-{{ ap.id }}">
<td colspan="10">
<td colspan="12">
<form id="augmented-part-edit-form-{{ ap.id }}"
hx-post="/admin/augmented-parts/{{ ap.id }}"
hx-target="#augmented-part-{{ ap.id }}"
@@ -45,6 +45,8 @@
{% endfor %}
</select>
</label>
<label class="check"><input type="checkbox" name="on_website" value="true" {{ "checked" if ap.on_website }}> On website</label>
<label class="check"><input type="checkbox" name="preferred_part" value="true" {{ "checked" if ap.preferred_part }}> Preferred part</label>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
@@ -44,6 +44,7 @@
{% endfor %}
</select>
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
<label><input type="checkbox" name="on_website" {{ "checked" if on_website }}> On website</label>
</form>
</div>
@@ -13,6 +13,8 @@
{{ sort_th("part_type", "Type", sort_headers) }}
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
<th>Archived</th>
<th>On Website</th>
<th>Preferred</th>
<th></th>
</tr>
</thead>
@@ -20,7 +22,7 @@
{% for ap in augmented_parts %}
{% include "admin/_augmented_part_row.html" %}
{% else %}
<tr><td colspan="10" class="muted">No augmented parts recorded.</td></tr>
<tr><td colspan="12" class="muted">No augmented parts recorded.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -12,6 +12,7 @@
{{ sort_th("ProductODmm", "OD (mm)", sort_headers) }}
{{ sort_th("ProductODin", "OD (in)", sort_headers) }}
{{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }}
{{ sort_th("UACDiameterRangemm", "UAC Diameter Range (mm)", sort_headers) }}
</tr>
</thead>
<tbody>
@@ -25,9 +26,10 @@
<td>{{ p.ProductODmm or '—' }}</td>
<td>{{ p.ProductODin or '—' }}</td>
<td>{{ p.TBXThreadAttribute or '—' }}</td>
<td>{{ p.UACDiameterRangemm or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="muted">No imported parts. Use Refresh to sync from OData.</td></tr>
<tr><td colspan="9" class="muted">No imported parts. Use Refresh to sync from OData.</td></tr>
{% endfor %}
</tbody>
</table>
+15 -15
View File
@@ -3,6 +3,21 @@
{% if sync_error %}<p class="alert alert-error">{{ sync_error }}</p>{% endif %}
{% if any_unlinked %}
<div class="filter-bar">
<form hx-get="/admin/augmented-parts/skipped/filter"
hx-target="#skipped-parts-table-card"
hx-trigger="input delay:300ms, change"
hx-swap="outerHTML">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
<select name="colour">
<option value="">All colours</option>
{% for c in skipped_colours %}
<option value="{{ c }}" {{ "selected" if filter_colour == c }}>{{ c }}</option>
{% endfor %}
</select>
</form>
</div>
<form hx-post="/admin/augmented-parts/skipped"
hx-target="#skipped-parts-list"
hx-swap="outerHTML">
@@ -29,21 +44,6 @@
</div>
</div>
<div class="filter-bar">
<form hx-get="/admin/augmented-parts/skipped/filter"
hx-target="#skipped-parts-table-card"
hx-trigger="input delay:300ms, change"
hx-swap="outerHTML">
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
<select name="colour">
<option value="">All colours</option>
{% for c in skipped_colours %}
<option value="{{ c }}" {{ "selected" if filter_colour == c }}>{{ c }}</option>
{% endfor %}
</select>
</form>
</div>
{% include "admin/_skipped_parts_table_card.html" %}
</form>
@@ -11,6 +11,8 @@
<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.on_website %}<span class="tag">On website</span>{% endif %}
{% if ap.preferred_part %}<span class="tag">Preferred</span>{% endif %}
{% if ap.description %}{{ ap.description }}{% endif %}
</p>
+5 -3
View File
@@ -5,8 +5,8 @@
<main class="container">
<h1>Augmented Parts</h1>
<div class="card">
<h2>Add part</h2>
<details class=card>
<summary>Add a part</summary>
<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>
@@ -50,9 +50,11 @@
{% endfor %}
</select>
</label>
<label class="check"><input type="checkbox" name="on_website" value="true"> On website</label>
<label class="check"><input type="checkbox" name="preferred_part" value="true"> Preferred part</label>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
</details>
<div class="card">
<h2>Sync with Imported Parts</h2>
+64 -8
View File
@@ -728,7 +728,7 @@ async def create_manufacturer(
created = await manufacturer_crud.create(
db,
name=name,
url=url,
url=url.strip() or None,
logo_url=logo_url.strip() or None,
)
await audit_crud.log(
@@ -789,7 +789,11 @@ async def update_manufacturer(
m = await manufacturer_crud.get(db, manufacturer_id)
if m is None:
raise HTTPException(status_code=404, detail="Manufacturer not found")
changes = {"name": name, "url": url, "logo_url": logo_url.strip() or None}
changes = {
"name": name,
"url": url.strip() or None,
"logo_url": logo_url.strip() or None,
}
await manufacturer_crud.update(db, m, changes)
await audit_crud.log(
db,
@@ -1043,6 +1047,9 @@ async def update_part_type(
pt = await part_type_crud.get(db, part_type_id)
if pt is None:
raise HTTPException(status_code=404, detail="Part type not found")
existing = await part_type_crud.get_by_name(db, name)
if existing is not None and existing.id != pt.id:
raise HTTPException(status_code=409, detail="Part type already exists")
changes = {"name": name}
await part_type_crud.update(db, pt, changes)
await audit_crud.log(
@@ -1170,6 +1177,11 @@ async def update_sub_part_type(
spt = await sub_part_type_crud.get(db, sub_part_type_id)
if spt is None:
raise HTTPException(status_code=404, detail="Sub part type not found")
existing = await sub_part_type_crud.get_by_name_and_part_type(
db, name, part_type_id
)
if existing is not None and existing.id != spt.id:
raise HTTPException(status_code=409, detail="Sub part type already exists")
changes = {"name": name, "part_type_id": part_type_id}
await sub_part_type_crud.update(db, spt, changes)
await audit_crud.log(
@@ -1221,6 +1233,7 @@ _IMPORTED_PARTS_SORT_COLUMNS = [
"ProductODmm",
"ProductODin",
"TBXThreadAttribute",
"UACDiameterRangemm",
]
@@ -1661,6 +1674,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
"sort": "description",
"dir": "asc",
"include_archived": False,
"on_website": False,
}
@@ -1670,23 +1684,30 @@ async def augmented_parts_page(
sort: str = "description",
dir: str = "asc",
include_archived: bool = False,
on_website: 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, include_archived=include_archived
db, include_archived=include_archived, on_website=on_website
)
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
db,
sort=sort,
direction=dir,
include_archived=include_archived,
on_website=on_website,
limit=_PAGE_SIZE,
)
ctx["pagination"] = _paginate(
"/admin/augmented-parts/filter",
{"sort": sort, "dir": dir, "include_archived": include_archived},
{
"sort": sort,
"dir": dir,
"include_archived": include_archived,
"on_website": on_website,
},
1,
_PAGE_SIZE,
total,
@@ -1694,7 +1715,7 @@ async def augmented_parts_page(
)
ctx["sort_headers"] = _sort_headers(
"/admin/augmented-parts/filter",
{"include_archived": include_archived},
{"include_archived": include_archived, "on_website": on_website},
_AUGMENTED_PARTS_SORT_COLUMNS,
sort,
dir,
@@ -1703,6 +1724,7 @@ async def augmented_parts_page(
ctx["sort"] = sort
ctx["dir"] = dir
ctx["include_archived"] = include_archived
ctx["on_website"] = on_website
ctx["user"] = user
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
@@ -1717,18 +1739,25 @@ async def create_augmented_part(
scope_model_id: str = Form(""),
part_type_id: int = Form(...),
sub_part_type_id: str = Form(""),
on_website: bool = Form(False),
preferred_part: bool = Form(False),
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("augmented_parts:write")),
):
clean_sku = sku.strip() or None
if clean_sku and await augmented_part_crud.get_by_sku(db, clean_sku):
raise HTTPException(status_code=409, detail="SKU already in use")
created = await augmented_part_crud.create(
db,
sku=sku.strip() or None,
sku=clean_sku,
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,
on_website=on_website,
preferred_part=preferred_part,
)
await audit_crud.log(
db,
@@ -1773,7 +1802,8 @@ async def sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
@@ -1781,6 +1811,7 @@ async def sync_augmented_parts(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.updated} updated, "
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"
@@ -1856,6 +1887,7 @@ async def filter_augmented_parts(
part_type_id: str = "",
sub_part_type_id: str = "",
include_archived: bool = False,
on_website: bool = False,
sort: str = "description",
dir: str = "asc",
page: int = 1,
@@ -1877,6 +1909,7 @@ async def filter_augmented_parts(
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
on_website=on_website,
)
filter_params = {
"description": description,
@@ -1886,6 +1919,7 @@ async def filter_augmented_parts(
"part_type_id": part_type_id,
"sub_part_type_id": sub_part_type_id,
"include_archived": include_archived,
"on_website": on_website,
}
pagination = _paginate(
"/admin/augmented-parts/filter",
@@ -1904,6 +1938,7 @@ async def filter_augmented_parts(
part_type_id=part_type_id_int,
sub_part_type_id=sub_part_type_id_int,
include_archived=include_archived,
on_website=on_website,
sort=sort,
direction=dir,
skip=pagination["skip"],
@@ -1927,6 +1962,7 @@ async def filter_augmented_parts(
"sort": sort,
"dir": dir,
"include_archived": include_archived,
"on_website": on_website,
},
)
@@ -2170,20 +2206,29 @@ async def update_augmented_part(
scope_model_id: str = Form(""),
part_type_id: int = Form(...),
sub_part_type_id: str = Form(""),
on_website: bool = Form(False),
preferred_part: bool = Form(False),
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")
clean_sku = sku.strip() or None
if clean_sku:
existing = await augmented_part_crud.get_by_sku(db, clean_sku)
if existing is not None and existing.id != ap.id:
raise HTTPException(status_code=409, detail="SKU already in use")
changes = {
"sku": sku.strip() or None,
"sku": clean_sku,
"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,
"on_website": on_website,
"preferred_part": preferred_part,
}
await augmented_part_crud.update(db, ap, changes)
await audit_crud.log(
@@ -3061,6 +3106,9 @@ async def update_part_colour(
pc = await part_colour_crud.get(db, part_colour_id)
if pc is None:
raise HTTPException(status_code=404, detail="Part colour not found")
existing = await part_colour_crud.get_by_name(db, name)
if existing is not None and existing.id != pc.id:
raise HTTPException(status_code=409, detail="Part colour already exists")
changes = {"name": name}
await part_colour_crud.update(db, pc, changes)
await audit_crud.log(
@@ -3180,6 +3228,11 @@ async def update_attachment_point_name(
apn = await apn_crud.get(db, apn_id)
if apn is None:
raise HTTPException(status_code=404, detail="Attachment point name not found")
existing = await apn_crud.get_by_name(db, name)
if existing is not None and existing.id != apn.id:
raise HTTPException(
status_code=409, detail="Attachment point name already exists"
)
changes = {"name": name}
await apn_crud.update(db, apn, changes)
await audit_crud.log(
@@ -3517,6 +3570,9 @@ async def update_static_connection(
sc = await static_connection_crud.get(db, static_connection_id)
if sc is None:
raise HTTPException(status_code=404, detail="Static connection not found")
existing = await static_connection_crud.get_by_od_mm(db, od_mm)
if existing is not None and existing.id != sc.id:
raise HTTPException(status_code=409, detail="Static connection already exists")
changes = {"od_mm": od_mm, "od_in": od_in}
await static_connection_crud.update(db, sc, changes)
await audit_crud.log(
+3 -1
View File
@@ -234,7 +234,8 @@ async def dashboard_sync_augmented_parts(
resource_type="AugmentedPart",
resource_id=None,
description=(
f"Sync: {result.created} created, {result.archived} archived, "
f"Sync: {result.created} created, {result.updated} updated, "
f"{result.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
@@ -242,6 +243,7 @@ async def dashboard_sync_augmented_parts(
job_id,
message=(
f"Sync complete: {result.created} created, "
f"{result.updated} updated, "
f"{result.archived} archived"
+ (
f", {result.skipped} skipped (type not recognised)"
+80
View File
@@ -210,6 +210,86 @@ async def test_patch_can_unarchive_an_augmented_part(
assert ap_id in [p["id"] for p in default_list.json()]
@pytest.mark.asyncio
async def test_patch_and_delete_accept_sku_as_identifier(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Sku addressed part",
sku="SKU-123",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add(ap)
await db.commit()
ap_id = ap.id
await make_user("root3@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root3@example.com')}"}
resp = await client.patch(
"/api/augmented-parts/SKU-123",
json={"description": "Renamed via sku"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["id"] == ap_id
assert resp.json()["description"] == "Renamed via sku"
resp = await client.delete("/api/augmented-parts/SKU-123", headers=headers)
assert resp.status_code == 204
assert (
await client.get(f"/api/augmented-parts/{ap_id}", headers=headers)
).status_code == 404
@pytest.mark.asyncio
async def test_delete_by_id_still_works(client, seeded, make_user, db_sessionmaker):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Delete me by id",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add(ap)
await db.commit()
ap_id = ap.id
await make_user("root4@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root4@example.com')}"}
resp = await client.delete(f"/api/augmented-parts/{ap_id}", headers=headers)
assert resp.status_code == 204
assert (
await client.get(f"/api/augmented-parts/{ap_id}", headers=headers)
).status_code == 404
@pytest.mark.asyncio
async def test_update_and_delete_by_unknown_sku_404s(
client, seeded, make_user, db_sessionmaker
):
await make_user("root5@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root5@example.com')}"}
resp = await client.patch(
"/api/augmented-parts/NO-SUCH-SKU",
json={"description": "won't happen"},
headers=headers,
)
assert resp.status_code == 404
resp = await client.delete("/api/augmented-parts/NO-SUCH-SKU", headers=headers)
assert resp.status_code == 404
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@@ -0,0 +1,226 @@
"""`on_website` and `preferred_part` booleans on AugmentedPart: default to
False, settable via create/update (API + admin web UI), and `on_website`
is filterable in the admin listing."""
import pytest
from app.models.augmented_part import AugmentedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
async def _seed_reference_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Scope")
db.add_all([manufacturer, colour, part_type])
await db.flush()
return manufacturer, colour, part_type
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _superuser_headers(client, make_user, email):
await make_user(email, is_superuser=True)
return {"Authorization": f"Bearer {await _token(client, email)}"}
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_api_create_defaults_both_fields_to_false(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
manufacturer_id, colour_id, part_type_id = (
manufacturer.id,
colour.id,
part_type.id,
)
await db.commit()
headers = await _superuser_headers(client, make_user, "owp-api1@example.com")
resp = await client.post(
"/api/augmented-parts",
json={
"description": "Widget",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["on_website"] is False
assert body["preferred_part"] is False
@pytest.mark.asyncio
async def test_api_create_and_update_can_set_both_fields(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
manufacturer_id, colour_id, part_type_id = (
manufacturer.id,
colour.id,
part_type.id,
)
await db.commit()
headers = await _superuser_headers(client, make_user, "owp-api2@example.com")
resp = await client.post(
"/api/augmented-parts",
json={
"description": "Widget",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
"on_website": True,
"preferred_part": True,
},
headers=headers,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["on_website"] is True
assert body["preferred_part"] is True
ap_id = body["id"]
resp = await client.patch(
f"/api/augmented-parts/{ap_id}",
json={"on_website": False},
headers=headers,
)
assert resp.status_code == 200
body = resp.json()
assert body["on_website"] is False
assert body["preferred_part"] is True # untouched by the partial update
@pytest.mark.asyncio
async def test_web_admin_create_form_sets_both_checkboxes(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
manufacturer_id, colour_id, part_type_id = (
manufacturer.id,
colour.id,
part_type.id,
)
await db.commit()
await make_user("owp-web1@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "owp-web1@example.com")
resp = await client.post(
"/admin/augmented-parts",
data={
"description": "Web widget",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
"on_website": "true",
"preferred_part": "true",
},
)
assert resp.status_code == 200
assert "Web widget" in resp.text
@pytest.mark.asyncio
async def test_web_admin_update_form_toggles_both_fields(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
ap = AugmentedPart(
description="Togglable widget",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
on_website=False,
preferred_part=False,
)
db.add(ap)
await db.commit()
ap_id, manufacturer_id, colour_id, part_type_id = (
ap.id,
manufacturer.id,
colour.id,
part_type.id,
)
await make_user("owp-web2@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "owp-web2@example.com")
resp = await client.post(
f"/admin/augmented-parts/{ap_id}",
data={
"description": "Togglable widget",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
"on_website": "true",
# preferred_part omitted, as an unchecked checkbox would be
},
)
assert resp.status_code == 200
async with db_sessionmaker() as db:
refreshed = await db.get(AugmentedPart, ap_id)
assert refreshed.on_website is True
assert refreshed.preferred_part is False
@pytest.mark.asyncio
async def test_web_admin_filter_by_on_website(
client, seeded, make_user, db_sessionmaker
):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
on_site = AugmentedPart(
description="On-site widget",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
on_website=True,
)
off_site = AugmentedPart(
description="Off-site widget",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
on_website=False,
)
db.add_all([on_site, off_site])
await db.commit()
await make_user("owp-web3@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "owp-web3@example.com")
resp = await client.get("/admin/augmented-parts")
assert "On-site widget" in resp.text
assert "Off-site widget" in resp.text
resp = await client.get("/admin/augmented-parts/filter?on_website=true")
assert resp.status_code == 200
assert "On-site widget" in resp.text
assert "Off-site widget" not in resp.text
resp = await client.get("/admin/augmented-parts/filter")
assert "On-site widget" in resp.text
assert "Off-site widget" in resp.text
+167
View File
@@ -0,0 +1,167 @@
"""Sync should refresh already-linked AugmentedParts' sync-owned fields when
their backing ImportedPart's data changes, add missing attachment points,
and never touch "extra" admin-owned fields or existing attachment points."""
import pytest
from sqlalchemy import select
from app.crud import attachment_point as attachment_point_crud
from app.crud import part_colour as part_colour_crud
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.services import augmented_part_sync
async def _seed_reference_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Scope")
db.add_all([manufacturer, colour, part_type])
await db.flush()
return manufacturer, colour, part_type
async def _get_ap_by_sku(db, sku):
return (
await db.execute(select(AugmentedPart).where(AugmentedPart.sku == sku))
).scalar_one()
@pytest.mark.asyncio
async def test_sync_refreshes_scalar_fields_on_already_linked_part(db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_reference_data(db)
db.add(
ImportedPart(
InventoryID="INV-REFRESH-1",
Description="Old Scope description",
TBXColour="Black",
)
)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 1
assert result.updated == 0
ap = await _get_ap_by_sku(db, "INV-REFRESH-1")
assert ap.description == "Old Scope description"
assert ap.uac_diameter_range_mm is None
# Simulate the source feed changing.
ip = await db.get(ImportedPart, "INV-REFRESH-1")
ip.Description = "New Scope description"
ip.TBXColour = "Tan"
ip.UACDiameterRangemm = "03505500"
await db.commit()
result2 = await augmented_part_sync.sync(db)
assert result2.created == 0
assert result2.updated == 1
await db.refresh(ap)
assert ap.description == "New Scope description"
assert ap.uac_diameter_range_mm == "03.50-55.00"
tan = await part_colour_crud.get_by_name(db, "Tan")
assert ap.colour_id == tan.id
# Re-running sync with no further source changes is a no-op.
result3 = await augmented_part_sync.sync(db)
assert result3.updated == 0
@pytest.mark.asyncio
async def test_sync_never_touches_extra_admin_owned_fields(db_sessionmaker):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
other_manufacturer = Manufacturer(name="OtherCo", url="https://other.test")
other_part_type = PartType(name="Flip Cover")
db.add_all([other_manufacturer, other_part_type])
await db.flush()
db.add(ImportedPart(InventoryID="INV-EXTRA-1", Description="A Scope widget"))
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 1
ap = await _get_ap_by_sku(db, "INV-EXTRA-1")
# Simulate an admin manually re-classifying the part after creation.
ap.part_type_id = other_part_type.id
ap.sub_part_type_id = None
ap.manufacturer_id = other_manufacturer.id
ap.archived = False
await db.commit()
admin_part_type_id = ap.part_type_id
admin_manufacturer_id = ap.manufacturer_id
# Source feed also changes, to prove refresh runs at all.
ip = await db.get(ImportedPart, "INV-EXTRA-1")
ip.Description = "A Scope widget, renamed"
await db.commit()
result2 = await augmented_part_sync.sync(db)
assert result2.updated == 1
await db.refresh(ap)
assert ap.description == "A Scope widget, renamed" # sync-owned: refreshed
assert ap.part_type_id == admin_part_type_id # extra field: untouched
assert ap.manufacturer_id == admin_manufacturer_id # extra field: untouched
assert ap.sku == "INV-EXTRA-1" # extra field: untouched
@pytest.mark.asyncio
async def test_sync_adds_missing_attachment_point_without_touching_existing_one(
db_sessionmaker,
):
async with db_sessionmaker() as db:
await _seed_reference_data(db)
db.add(
ImportedPart(
InventoryID="INV-AP-1",
Description="A Scope widget",
# No thread/OD/UAC data yet — no attachment points created.
)
)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 1
ap = await _get_ap_by_sku(db, "INV-AP-1")
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
assert points == []
# Source feed gains an OD value -> an "Ocular" point should appear.
ip = await db.get(ImportedPart, "INV-AP-1")
ip.ProductODmm = "40"
ip.ProductODin = "1.575"
await db.commit()
result2 = await augmented_part_sync.sync(db)
assert result2.updated == 1
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
assert len(points) == 1
ocular = points[0]
assert ocular.attachment_point_name.name == "Ocular"
original_static_connection_id = ocular.static_connection_id
# Source OD changes again — the existing point must NOT be replaced.
ip.ProductODmm = "56"
ip.ProductODin = "2.205"
await db.commit()
result3 = await augmented_part_sync.sync(db)
# description/colour/uac unchanged, and the Ocular point already
# exists, so nothing should be reported as updated.
assert result3.updated == 0
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
assert len(points) == 1
assert points[0].id == ocular.id
assert points[0].static_connection_id == original_static_connection_id
+156
View File
@@ -0,0 +1,156 @@
"""UACDiameterRangemm handling in the AugmentedPart sync service: the
reformatted range field on AugmentedPart, and the "Objective" clamp
attachment point it should produce."""
import pytest
from sqlalchemy import select
from app.crud import attachment_point as attachment_point_crud
from app.crud import clamp_connection as clamp_connection_crud
from app.models.augmented_part import AugmentedPart
from app.models.imported_part import ImportedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
from app.services import augmented_part_sync
from app.services.augmented_part_sync import _parse_uac_diameter_range
def test_parse_uac_diameter_range_valid_8_digits():
assert _parse_uac_diameter_range("03505500") == ("03.50-55.00", 3.5, 55.0)
@pytest.mark.parametrize("value", [None, "", "1234567", "123456789", "abcdefgh"])
def test_parse_uac_diameter_range_rejects_non_8_digit(value):
assert _parse_uac_diameter_range(value) is None
def test_parse_uac_diameter_range_strips_non_digit_separators():
# Defensive: pull digits out even if the feed adds punctuation.
assert _parse_uac_diameter_range("0350-5500") == ("03.50-55.00", 3.5, 55.0)
async def _seed_reference_data(db):
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Scope")
db.add_all([manufacturer, colour, part_type])
await db.flush()
return manufacturer, colour, part_type
@pytest.mark.asyncio
async def test_sync_populates_range_and_creates_objective_clamp_point(
db_sessionmaker,
):
async with db_sessionmaker() as db:
await _seed_reference_data(db)
db.add(
ImportedPart(
InventoryID="INV-UAC-1",
Description="Scope widget",
UACDiameterRangemm="03505500",
)
)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 1
ap = (
await db.execute(
select(AugmentedPart).where(AugmentedPart.sku == "INV-UAC-1")
)
).scalar_one()
assert ap.uac_diameter_range_mm == "03.50-55.00"
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
objective_points = [
p for p in points if p.attachment_point_name.name == "Objective"
]
assert len(objective_points) == 1
clamp = await clamp_connection_crud.get(
db, objective_points[0].clamp_connection_id
)
assert clamp.min_mm == 3.5
assert clamp.max_mm == 55.0
assert clamp.min_in == pytest.approx(3.5 / 25.4)
assert clamp.max_in == pytest.approx(55.0 / 25.4)
@pytest.mark.asyncio
async def test_sync_leaves_range_and_objective_point_unset_when_field_blank(
db_sessionmaker,
):
async with db_sessionmaker() as db:
await _seed_reference_data(db)
db.add(ImportedPart(InventoryID="INV-NO-UAC", Description="Scope widget"))
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 1
ap = (
await db.execute(
select(AugmentedPart).where(AugmentedPart.sku == "INV-NO-UAC")
)
).scalar_one()
assert ap.uac_diameter_range_mm is None
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
assert all(p.attachment_point_name.name != "Objective" for p in points)
@pytest.mark.asyncio
async def test_sync_reuses_existing_clamp_connection_for_same_range(db_sessionmaker):
async with db_sessionmaker() as db:
await _seed_reference_data(db)
db.add_all(
[
ImportedPart(
InventoryID="INV-UAC-A",
Description="Scope widget A",
UACDiameterRangemm="03505500",
),
ImportedPart(
InventoryID="INV-UAC-B",
Description="Scope widget B",
UACDiameterRangemm="03505500",
),
]
)
await db.commit()
result = await augmented_part_sync.sync(db)
assert result.created == 2
clamps = await clamp_connection_crud.list_all(db)
assert len(clamps) == 1
@pytest.mark.asyncio
async def test_sync_one_populates_range_and_objective_point(db_sessionmaker):
async with db_sessionmaker() as db:
manufacturer, colour, part_type = await _seed_reference_data(db)
db.add(
ImportedPart(
InventoryID="INV-UAC-MANUAL",
Description="Odd widget",
UACDiameterRangemm="10002000",
)
)
await db.commit()
ap = await augmented_part_sync.sync_one(
db,
inventory_id="INV-UAC-MANUAL",
part_type_id=part_type.id,
sub_part_type_id=None,
)
assert ap.uac_diameter_range_mm == "10.00-20.00"
points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
objective_points = [
p for p in points if p.attachment_point_name.name == "Objective"
]
assert len(objective_points) == 1
+321
View File
@@ -0,0 +1,321 @@
"""Duplicate-value pre-checks (409) for every unique-constrained field, across
both the JSON API (app/api/*.py) and the HTMX admin UI (app/web/admin.py)."""
import pytest
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _superuser_headers(client, make_user, email):
await make_user(email, is_superuser=True)
return {"Authorization": f"Bearer {await _token(client, email)}"}
async def _web_login(client, email, password="secret123"):
resp = await client.post("/login", data={"email": email, "password": password})
assert resp.status_code == 303
@pytest.mark.asyncio
async def test_part_type_duplicate_name_409_on_create_and_update(
client, seeded, make_user
):
headers = await _superuser_headers(client, make_user, "pt-api@example.com")
resp = await client.post("/api/part-types", json={"name": "Scope"}, headers=headers)
assert resp.status_code == 201
other = (
await client.post("/api/part-types", json={"name": "Cover"}, headers=headers)
).json()
resp = await client.post("/api/part-types", json={"name": "Scope"}, headers=headers)
assert resp.status_code == 409
resp = await client.patch(
f"/api/part-types/{other['id']}", json={"name": "Scope"}, headers=headers
)
assert resp.status_code == 409
# Renaming to the resource's own current name is not a false-positive collision.
resp = await client.patch(
f"/api/part-types/{other['id']}", json={"name": "Cover"}, headers=headers
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_part_colour_duplicate_name_409_on_create_and_update(
client, seeded, make_user
):
headers = await _superuser_headers(client, make_user, "pc-api@example.com")
assert (
await client.post("/api/part-colours", json={"name": "Black"}, headers=headers)
).status_code == 201
other = (
await client.post("/api/part-colours", json={"name": "Tan"}, headers=headers)
).json()
resp = await client.post(
"/api/part-colours", json={"name": "Black"}, headers=headers
)
assert resp.status_code == 409
resp = await client.patch(
f"/api/part-colours/{other['id']}", json={"name": "Black"}, headers=headers
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_attachment_point_name_duplicate_409_on_create_and_update(
client, seeded, make_user
):
headers = await _superuser_headers(client, make_user, "apn-api@example.com")
assert (
await client.post(
"/api/attachment-point-names", json={"name": "Ocular"}, headers=headers
)
).status_code == 201
other = (
await client.post(
"/api/attachment-point-names", json={"name": "Objective"}, headers=headers
)
).json()
resp = await client.post(
"/api/attachment-point-names", json={"name": "Ocular"}, headers=headers
)
assert resp.status_code == 409
resp = await client.patch(
f"/api/attachment-point-names/{other['id']}",
json={"name": "Ocular"},
headers=headers,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_static_connection_duplicate_od_mm_409_on_create_and_update(
client, seeded, make_user
):
headers = await _superuser_headers(client, make_user, "sc-api@example.com")
assert (
await client.post(
"/api/static-connections",
json={"od_mm": 40.0, "od_in": 1.575},
headers=headers,
)
).status_code == 201
other = (
await client.post(
"/api/static-connections",
json={"od_mm": 56.0, "od_in": 2.205},
headers=headers,
)
).json()
resp = await client.post(
"/api/static-connections", json={"od_mm": 40.0, "od_in": 1.6}, headers=headers
)
assert resp.status_code == 409
resp = await client.patch(
f"/api/static-connections/{other['id']}",
json={"od_mm": 40.0},
headers=headers,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_sub_part_type_duplicate_composite_409_on_create_and_update(
client, seeded, make_user
):
headers = await _superuser_headers(client, make_user, "spt-api@example.com")
part_type = (
await client.post("/api/part-types", json={"name": "Scope"}, headers=headers)
).json()
assert (
await client.post(
"/api/sub-part-types",
json={"name": "FFP", "part_type_id": part_type["id"]},
headers=headers,
)
).status_code == 201
other = (
await client.post(
"/api/sub-part-types",
json={"name": "SFP", "part_type_id": part_type["id"]},
headers=headers,
)
).json()
resp = await client.post(
"/api/sub-part-types",
json={"name": "FFP", "part_type_id": part_type["id"]},
headers=headers,
)
assert resp.status_code == 409
resp = await client.patch(
f"/api/sub-part-types/{other['id']}", json={"name": "FFP"}, headers=headers
)
assert resp.status_code == 409
# Same name, but a *different* part_type_id, is not a collision.
other_part_type = (
await client.post("/api/part-types", json={"name": "Cover"}, headers=headers)
).json()
resp = await client.post(
"/api/sub-part-types",
json={"name": "FFP", "part_type_id": other_part_type["id"]},
headers=headers,
)
assert resp.status_code == 201
@pytest.mark.asyncio
async def test_role_duplicate_name_409_on_create_and_update(client, seeded, make_user):
headers = await _superuser_headers(client, make_user, "role-api@example.com")
assert (
await client.post("/api/roles", json={"name": "editors"}, headers=headers)
).status_code == 201
other = (
await client.post("/api/roles", json={"name": "viewers"}, headers=headers)
).json()
resp = await client.post("/api/roles", json={"name": "editors"}, headers=headers)
assert resp.status_code == 409
resp = await client.patch(
f"/api/roles/{other['id']}", json={"name": "editors"}, headers=headers
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_user_duplicate_email_409_on_create_and_update(client, seeded, make_user):
headers = await _superuser_headers(client, make_user, "user-api@example.com")
assert (
await client.post(
"/api/users",
json={"email": "dup@example.com", "password": "secret123"},
headers=headers,
)
).status_code == 201
other = (
await client.post(
"/api/users",
json={"email": "other@example.com", "password": "secret123"},
headers=headers,
)
).json()
resp = await client.post(
"/api/users",
json={"email": "dup@example.com", "password": "secret123"},
headers=headers,
)
assert resp.status_code == 409
resp = await client.patch(
f"/api/users/{other['id']}", json={"email": "dup@example.com"}, headers=headers
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_admin_ui_update_part_type_duplicate_name_409(
client, seeded, make_user, db_sessionmaker
):
from app.crud import part_type as part_type_crud
async with db_sessionmaker() as db:
await part_type_crud.create(db, name="Scope")
cover = await part_type_crud.create(db, name="Cover")
cover_id = cover.id
await make_user("pt-web@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "pt-web@example.com")
resp = await client.post(f"/admin/part-types/{cover_id}", data={"name": "Scope"})
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_admin_ui_augmented_part_duplicate_sku_409_on_create_and_update(
client, seeded, make_user, db_sessionmaker
):
from app.models.augmented_part import AugmentedPart
from app.models.manufacturer import Manufacturer
from app.models.part_colour import PartColour
from app.models.part_type import PartType
async with db_sessionmaker() as db:
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
colour = PartColour(name="Black")
part_type = PartType(name="Scope")
db.add_all([manufacturer, colour, part_type])
await db.flush()
existing = AugmentedPart(
description="Taken sku part",
sku="DUP-1",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
other = AugmentedPart(
description="Other part",
colour_id=colour.id,
manufacturer_id=manufacturer.id,
part_type_id=part_type.id,
)
db.add_all([existing, other])
await db.commit()
manufacturer_id, colour_id, part_type_id = (
manufacturer.id,
colour.id,
part_type.id,
)
other_id = other.id
await make_user("ap-web@example.com", role_ids=[seeded["roles"]["admin"].id])
await _web_login(client, "ap-web@example.com")
resp = await client.post(
"/admin/augmented-parts",
data={
"sku": "DUP-1",
"description": "New part",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
},
)
assert resp.status_code == 409
resp = await client.post(
f"/admin/augmented-parts/{other_id}",
data={
"sku": "DUP-1",
"description": "Other part",
"colour_id": colour_id,
"manufacturer_id": manufacturer_id,
"part_type_id": part_type_id,
},
)
assert resp.status_code == 409