Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81c368663 | ||
|
|
8860c5691f | ||
|
|
054770d370 | ||
|
|
4fd2bb1b58 | ||
|
|
13b64a2d17 | ||
|
|
d23f5b9e82 | ||
|
|
597cf68157 | ||
|
|
d8db87c13e | ||
|
|
af5343da0c | ||
|
|
90cae15551 | ||
|
|
16ba09f073 | ||
|
|
53fe03fb2d |
No files matched your search
@@ -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")
|
||||||
@@ -33,6 +33,10 @@ async def create_attachment_point_name(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("attachment_point_names:write")),
|
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)
|
apn = await apn_crud.create(db, name=payload.name)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -68,6 +72,12 @@ async def update_attachment_point_name(
|
|||||||
if apn is None:
|
if apn is None:
|
||||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
apn = await apn_crud.update(db, apn, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
+28
-12
@@ -20,6 +20,15 @@ from app.services import augmented_part_sync
|
|||||||
router = APIRouter()
|
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])
|
@router.get("", response_model=list[AugmentedPartRead])
|
||||||
async def list_augmented_parts(
|
async def list_augmented_parts(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -39,6 +48,8 @@ async def create_augmented_part(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
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(
|
ap = await augmented_part_crud.create(
|
||||||
db,
|
db,
|
||||||
sku=payload.sku,
|
sku=payload.sku,
|
||||||
@@ -48,6 +59,8 @@ async def create_augmented_part(
|
|||||||
scope_model_id=payload.scope_model_id,
|
scope_model_id=payload.scope_model_id,
|
||||||
part_type_id=payload.part_type_id,
|
part_type_id=payload.part_type_id,
|
||||||
sub_part_type_id=payload.sub_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(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -75,8 +88,8 @@ async def sync_augmented_parts(
|
|||||||
resource_type="AugmentedPart",
|
resource_type="AugmentedPart",
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
description=(
|
description=(
|
||||||
f"Sync: {result.created} created, {result.archived} archived, "
|
f"Sync: {result.created} created, {result.updated} updated, "
|
||||||
f"{result.skipped} skipped (no type match)"
|
f"{result.archived} archived, {result.skipped} skipped (no type match)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
@@ -94,17 +107,21 @@ async def get_augmented_part(
|
|||||||
return ap
|
return ap
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
|
@router.patch("/{identifier}", response_model=AugmentedPartRead)
|
||||||
async def update_augmented_part(
|
async def update_augmented_part(
|
||||||
augmented_part_id: int,
|
identifier: str,
|
||||||
payload: AugmentedPartUpdate,
|
payload: AugmentedPartUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
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:
|
if ap is None:
|
||||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
ap = await augmented_part_crud.update(db, ap, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -117,15 +134,16 @@ async def update_augmented_part(
|
|||||||
return ap
|
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(
|
async def delete_augmented_part(
|
||||||
augmented_part_id: int,
|
identifier: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("augmented_parts:delete")),
|
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:
|
if ap is None:
|
||||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||||
|
resolved_id = ap.id
|
||||||
label = ap.sku or ap.description
|
label = ap.sku or ap.description
|
||||||
await augmented_part_crud.delete(db, ap)
|
await augmented_part_crud.delete(db, ap)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -133,10 +151,8 @@ async def delete_augmented_part(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
action="delete",
|
action="delete",
|
||||||
resource_type="AugmentedPart",
|
resource_type="AugmentedPart",
|
||||||
resource_id=augmented_part_id,
|
resource_id=resolved_id,
|
||||||
description=audit_crud.describe_delete(
|
description=audit_crud.describe_delete("AugmentedPart", resolved_id, label),
|
||||||
"AugmentedPart", augmented_part_id, label
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ async def create_part_colour(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("part_colours:write")),
|
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)
|
pc = await part_colour_crud.create(db, name=payload.name)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -62,6 +64,10 @@ async def update_part_colour(
|
|||||||
if pc is None:
|
if pc is None:
|
||||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
pc = await part_colour_crud.update(db, pc, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ async def create_part_type(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("part_types:write")),
|
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)
|
pt = await part_type_crud.create(db, name=payload.name)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -66,6 +70,12 @@ async def update_part_type(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
||||||
)
|
)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
pt = await part_type_crud.update(db, pt, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ async def update_role(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||||
)
|
)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
role = await role_crud.update(db, role, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ async def create_static_connection(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("static_connections:write")),
|
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(
|
sc = await static_connection_crud.create(
|
||||||
db, od_mm=payload.od_mm, od_in=payload.od_in
|
db, od_mm=payload.od_mm, od_in=payload.od_in
|
||||||
)
|
)
|
||||||
@@ -72,6 +74,12 @@ async def update_static_connection(
|
|||||||
if sc is None:
|
if sc is None:
|
||||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
sc = await static_connection_crud.update(db, sc, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ async def create_sub_part_type(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(require_permission("sub_part_types:write")),
|
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(
|
spt = await sub_part_type_crud.create(
|
||||||
db, name=payload.name, part_type_id=payload.part_type_id
|
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"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||||
)
|
)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
spt = await sub_part_type_crud.update(db, spt, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ async def update_user(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
)
|
)
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
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)
|
target = await user_crud.update(db, target, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ from app.models.part_type import PartType
|
|||||||
from app.models.scope_model import ScopeModel
|
from app.models.scope_model import ScopeModel
|
||||||
from app.models.sub_part_type import SubPartType
|
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.
|
# 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
|
# 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,
|
part_type_id: int | None = None,
|
||||||
sub_part_type_id: int | None = None,
|
sub_part_type_id: int | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
on_website: bool = False,
|
||||||
sort: str = "description",
|
sort: str = "description",
|
||||||
direction: str = "asc",
|
direction: str = "asc",
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
@@ -110,6 +118,8 @@ async def list_filtered(
|
|||||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
q = q.where(AugmentedPart.archived.is_(False))
|
q = q.where(AugmentedPart.archived.is_(False))
|
||||||
|
if on_website:
|
||||||
|
q = q.where(AugmentedPart.on_website.is_(True))
|
||||||
q = _apply_sort(q, sort, direction)
|
q = _apply_sort(q, sort, direction)
|
||||||
result = await db.execute(q.offset(skip).limit(limit))
|
result = await db.execute(q.offset(skip).limit(limit))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -124,6 +134,7 @@ async def count_filtered(
|
|||||||
part_type_id: int | None = None,
|
part_type_id: int | None = None,
|
||||||
sub_part_type_id: int | None = None,
|
sub_part_type_id: int | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
on_website: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
@@ -145,6 +156,8 @@ async def count_filtered(
|
|||||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
q = q.where(AugmentedPart.archived.is_(False))
|
q = q.where(AugmentedPart.archived.is_(False))
|
||||||
|
if on_website:
|
||||||
|
q = q.where(AugmentedPart.on_website.is_(True))
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@@ -220,6 +233,8 @@ async def create(
|
|||||||
scope_model_id: int | None,
|
scope_model_id: int | None,
|
||||||
part_type_id: int,
|
part_type_id: int,
|
||||||
sub_part_type_id: int | None,
|
sub_part_type_id: int | None,
|
||||||
|
on_website: bool = False,
|
||||||
|
preferred_part: bool = False,
|
||||||
) -> AugmentedPart:
|
) -> AugmentedPart:
|
||||||
ap = AugmentedPart(
|
ap = AugmentedPart(
|
||||||
sku=sku,
|
sku=sku,
|
||||||
@@ -229,6 +244,8 @@ async def create(
|
|||||||
scope_model_id=scope_model_id,
|
scope_model_id=scope_model_id,
|
||||||
part_type_id=part_type_id,
|
part_type_id=part_type_id,
|
||||||
sub_part_type_id=sub_part_type_id,
|
sub_part_type_id=sub_part_type_id,
|
||||||
|
on_website=on_website,
|
||||||
|
preferred_part=preferred_part,
|
||||||
)
|
)
|
||||||
db.add(ap)
|
db.add(ap)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ async def list_all(
|
|||||||
return list(result.scalars().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(
|
async def create(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ _FIELDS = frozenset(
|
|||||||
"ProductODmm",
|
"ProductODmm",
|
||||||
"ProductODin",
|
"ProductODin",
|
||||||
"TBXThreadAttribute",
|
"TBXThreadAttribute",
|
||||||
|
"UACDiameterRangemm",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ SORT_COLUMNS = {
|
|||||||
"ProductODmm": ImportedPart.ProductODmm,
|
"ProductODmm": ImportedPart.ProductODmm,
|
||||||
"ProductODin": ImportedPart.ProductODin,
|
"ProductODin": ImportedPart.ProductODin,
|
||||||
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
|
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
|
||||||
|
"UACDiameterRangemm": ImportedPart.UACDiameterRangemm,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async def create(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
url: str,
|
url: str | None = None,
|
||||||
logo_url: str | None = None,
|
logo_url: str | None = None,
|
||||||
) -> Manufacturer:
|
) -> Manufacturer:
|
||||||
m = Manufacturer(name=name, url=url, logo_url=logo_url)
|
m = Manufacturer(name=name, url=url, logo_url=logo_url)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class AugmentedPart(Base):
|
|||||||
__tablename__ = "augmented_parts"
|
__tablename__ = "augmented_parts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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)
|
description: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
colour_id: Mapped[int] = mapped_column(
|
colour_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
|
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
|
||||||
@@ -36,10 +38,17 @@ class AugmentedPart(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
default=None,
|
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
|
# Set instead of deleting the row when the last backing ImportedPart is
|
||||||
# removed, so fit/attachment-point history is preserved. Hidden from list
|
# removed, so fit/attachment-point history is preserved. Hidden from list
|
||||||
# views by default.
|
# views by default.
|
||||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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
|
# linked imported parts — not eagerly loaded; used by sync service only
|
||||||
imported_parts: Mapped[list[ImportedPart]] = relationship(
|
imported_parts: Mapped[list[ImportedPart]] = relationship(
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ class ImportedPart(Base):
|
|||||||
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||||
ProductODin: 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)
|
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||||
|
UACDiameterRangemm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||||
@@ -8,5 +8,5 @@ class Manufacturer(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
url: Mapped[str] = mapped_column(nullable=False)
|
url: Mapped[str | None] = mapped_column(nullable=True)
|
||||||
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||||
@@ -39,6 +39,8 @@ class AugmentedPartBase(BaseModel):
|
|||||||
scope_model_id: int | None = None
|
scope_model_id: int | None = None
|
||||||
part_type_id: int
|
part_type_id: int
|
||||||
sub_part_type_id: int | None = None
|
sub_part_type_id: int | None = None
|
||||||
|
on_website: bool = False
|
||||||
|
preferred_part: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AugmentedPartCreate(AugmentedPartBase):
|
class AugmentedPartCreate(AugmentedPartBase):
|
||||||
@@ -54,10 +56,13 @@ class AugmentedPartUpdate(BaseModel):
|
|||||||
part_type_id: int | None = None
|
part_type_id: int | None = None
|
||||||
sub_part_type_id: int | None = None
|
sub_part_type_id: int | None = None
|
||||||
archived: bool | None = None
|
archived: bool | None = None
|
||||||
|
on_website: bool | None = None
|
||||||
|
preferred_part: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class AugmentedPartSyncResult(BaseModel):
|
class AugmentedPartSyncResult(BaseModel):
|
||||||
created: int
|
created: int
|
||||||
|
updated: int
|
||||||
archived: int
|
archived: int
|
||||||
skipped: int
|
skipped: int
|
||||||
|
|
||||||
@@ -67,6 +72,7 @@ class AugmentedPartRead(AugmentedPartBase):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
archived: bool
|
archived: bool
|
||||||
|
uac_diameter_range_mm: str | None
|
||||||
colour: PartColourRead
|
colour: PartColourRead
|
||||||
manufacturer: ManufacturerRead
|
manufacturer: ManufacturerRead
|
||||||
scope_model: ScopeModelRead | None
|
scope_model: ScopeModelRead | None
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ImportedPartRead(BaseModel):
|
|||||||
ProductODmm: str | None
|
ProductODmm: str | None
|
||||||
ProductODin: str | None
|
ProductODin: str | None
|
||||||
TBXThreadAttribute: str | None
|
TBXThreadAttribute: str | None
|
||||||
|
UACDiameterRangemm: str | None
|
||||||
|
|
||||||
|
|
||||||
class ImportedPartConfigRead(BaseModel):
|
class ImportedPartConfigRead(BaseModel):
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
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):
|
class ManufacturerBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
url: HttpUrl
|
url: HttpUrl | None = None
|
||||||
logo_url: HttpUrl | None = None
|
logo_url: HttpUrl | None = None
|
||||||
|
|
||||||
|
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerCreate(ManufacturerBase):
|
class ManufacturerCreate(ManufacturerBase):
|
||||||
pass
|
pass
|
||||||
@@ -16,6 +24,8 @@ class ManufacturerUpdate(BaseModel):
|
|||||||
url: HttpUrl | None = None
|
url: HttpUrl | None = None
|
||||||
logo_url: HttpUrl | None = None
|
logo_url: HttpUrl | None = None
|
||||||
|
|
||||||
|
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerRead(ManufacturerBase):
|
class ManufacturerRead(ManufacturerBase):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -9,7 +9,24 @@ Phase 1 – archive stale entries
|
|||||||
remaining associations (i.e. it was created purely from now-deleted
|
remaining associations (i.e. it was created purely from now-deleted
|
||||||
ImportedParts), preserving its fit/attachment-point history.
|
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.
|
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
|
||||||
• Description ← ImportedPart.Description (fallback: InventoryID)
|
• Description ← ImportedPart.Description (fallback: InventoryID)
|
||||||
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
|
• 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
|
unit is missing is derived via mm/in conversion) if the
|
||||||
thread attribute is absent or unparseable
|
thread attribute is absent or unparseable
|
||||||
Skipped entirely if none of those fields are usable.
|
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
|
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 as attachment_point_crud
|
||||||
from app.crud import attachment_point_name as attachment_point_name_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 clamp_connection as clamp_connection_crud
|
||||||
from app.crud import iso_thread as iso_thread_crud
|
from app.crud import iso_thread as iso_thread_crud
|
||||||
from app.crud import manufacturer as manufacturer_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
|
from app.schemas.augmented_part import AugmentedPartSyncResult
|
||||||
|
|
||||||
ATTACHMENT_POINT_NAME = "Ocular"
|
ATTACHMENT_POINT_NAME = "Ocular"
|
||||||
|
UAC_ATTACHMENT_POINT_NAME = "Objective"
|
||||||
MM_PER_INCH = 25.4
|
MM_PER_INCH = 25.4
|
||||||
|
|
||||||
# e.g.
|
# e.g.
|
||||||
@@ -118,6 +143,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
|
|||||||
return 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):
|
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
|
||||||
apn = await attachment_point_name_crud.get_by_name(db, name)
|
apn = await attachment_point_name_crud.get_by_name(db, name)
|
||||||
if apn is None:
|
if apn is None:
|
||||||
@@ -233,6 +301,8 @@ async def sync_one(
|
|||||||
if manufacturer is None:
|
if manufacturer is None:
|
||||||
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
|
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
|
||||||
|
|
||||||
|
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||||
|
|
||||||
ap = AugmentedPart(
|
ap = AugmentedPart(
|
||||||
sku=ip.InventoryID,
|
sku=ip.InventoryID,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -241,6 +311,7 @@ async def sync_one(
|
|||||||
scope_model_id=None,
|
scope_model_id=None,
|
||||||
part_type_id=part_type_id,
|
part_type_id=part_type_id,
|
||||||
sub_part_type_id=sub_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)
|
db.add(ap)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -252,6 +323,7 @@ async def sync_one(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _add_attachment_point(db, ap, ip)
|
await _add_attachment_point(db, ap, ip)
|
||||||
|
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return ap
|
return ap
|
||||||
|
|
||||||
@@ -354,6 +426,52 @@ async def delete_imported_part_and_archive_orphans(
|
|||||||
return archived
|
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:
|
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||||
# ------------------------------------------------------------------ phase 1
|
# ------------------------------------------------------------------ phase 1
|
||||||
|
|
||||||
@@ -376,19 +494,45 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ phase 2
|
# ------------------------------------------------------------------ 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(
|
result = await db.execute(
|
||||||
select(augmented_part_imported_parts.c.imported_part_id).distinct()
|
select(augmented_part_imported_parts.c.imported_part_id).distinct()
|
||||||
)
|
)
|
||||||
now_linked_ids: set[str] = {row[0] for row in result.all()}
|
now_linked_ids: set[str] = {row[0] for row in result.all()}
|
||||||
unlinked_ids = current_ids - now_linked_ids
|
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
|
created = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
if not unlinked_ids:
|
if not unlinked_ids:
|
||||||
return AugmentedPartSyncResult(
|
return AugmentedPartSyncResult(
|
||||||
created=created, archived=archived, skipped=skipped
|
created=created, updated=updated, archived=archived, skipped=skipped
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -416,6 +560,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
if colour is None:
|
if colour is None:
|
||||||
colour = await part_colour_crud.create(db, name=colour_name)
|
colour = await part_colour_crud.create(db, name=colour_name)
|
||||||
|
|
||||||
|
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||||
|
|
||||||
ap = AugmentedPart(
|
ap = AugmentedPart(
|
||||||
sku=ip.InventoryID,
|
sku=ip.InventoryID,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -424,6 +570,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
scope_model_id=None,
|
scope_model_id=None,
|
||||||
part_type_id=part_type.id,
|
part_type_id=part_type.id,
|
||||||
sub_part_type_id=None,
|
sub_part_type_id=None,
|
||||||
|
uac_diameter_range_mm=uac_range[0] if uac_range else None,
|
||||||
)
|
)
|
||||||
db.add(ap)
|
db.add(ap)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -435,8 +582,11 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _add_attachment_point(db, ap, ip)
|
await _add_attachment_point(db, ap, ip)
|
||||||
|
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
|
return AugmentedPartSyncResult(
|
||||||
|
created=created, updated=updated, archived=archived, skipped=skipped
|
||||||
|
)
|
||||||
@@ -145,11 +145,15 @@ def parse_workbook(data: bytes) -> list[dict]:
|
|||||||
wb.close()
|
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``."""
|
"""Create an attachment point carrying a StaticConnection for ``od_mm``."""
|
||||||
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
|
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
|
||||||
if sc is None:
|
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)
|
apn = await apn_crud.get_by_name(db, label)
|
||||||
if apn is None:
|
if apn is None:
|
||||||
apn = await apn_crud.create(db, name=label)
|
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."""
|
"""Create an attachment point for a parsed ISO/UNC thread, or None."""
|
||||||
parsed = _parse_thread_attribute(spec)
|
parsed = _parse_thread_attribute(spec)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
@@ -268,14 +274,18 @@ async def _link_fit(
|
|||||||
if target_aps:
|
if target_aps:
|
||||||
target_ap = target_aps[0]
|
target_ap = target_aps[0]
|
||||||
else:
|
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(
|
messages.append(
|
||||||
f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
|
f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
|
||||||
"created one matching the scope's attachment point"
|
"created one matching the scope's attachment point"
|
||||||
)
|
)
|
||||||
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
|
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
|
||||||
if existing is None:
|
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:
|
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"))
|
model = _text(row.get("model"))
|
||||||
magnification_min = _num(row.get("magnification_min"))
|
magnification_min = _num(row.get("magnification_min"))
|
||||||
magnification_max = _num(row.get("magnification_max"))
|
magnification_max = _num(row.get("magnification_max"))
|
||||||
objective_lens_diameter_mm = _num(row.get("objective_lens_diameter_mm")) or _num(
|
objective_lens_diameter_mm = _num(
|
||||||
row.get("objective_lens")
|
row.get("objective_lens_diameter_mm")
|
||||||
)
|
) or _num(row.get("objective_lens"))
|
||||||
|
|
||||||
# Construct the model number
|
# Construct the model number
|
||||||
default_product_number = model
|
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)
|
manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name)
|
||||||
if manufacturer is None:
|
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"))
|
model_name = _text(row.get("model"))
|
||||||
url = _text(row.get("url"))
|
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
|
# Rebuild attachment points from scratch so re-imports stay idempotent
|
||||||
# (cascades delete any fits that referenced the old points).
|
# (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)
|
await attachment_point_crud.delete(db, old_ap)
|
||||||
|
|
||||||
ocular_ap = None
|
ocular_ap = None
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<td>{{ ap.part_type.name }}</td>
|
<td>{{ ap.part_type.name }}</td>
|
||||||
<td>{{ ap.sub_part_type.name if ap.sub_part_type else '—' }}</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.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">
|
<td class="row-actions">
|
||||||
<button class="btn btn-ghost"
|
<button class="btn btn-ghost"
|
||||||
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
|
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<tr id="augmented-part-{{ ap.id }}">
|
<tr id="augmented-part-{{ ap.id }}">
|
||||||
<td colspan="10">
|
<td colspan="12">
|
||||||
<form id="augmented-part-edit-form-{{ ap.id }}"
|
<form id="augmented-part-edit-form-{{ ap.id }}"
|
||||||
hx-post="/admin/augmented-parts/{{ ap.id }}"
|
hx-post="/admin/augmented-parts/{{ ap.id }}"
|
||||||
hx-target="#augmented-part-{{ ap.id }}"
|
hx-target="#augmented-part-{{ ap.id }}"
|
||||||
@@ -45,6 +45,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<div class="row-actions">
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
<button type="button" class="btn btn-ghost"
|
<button type="button" class="btn btn-ghost"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
{{ sort_th("part_type", "Type", sort_headers) }}
|
{{ sort_th("part_type", "Type", sort_headers) }}
|
||||||
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
|
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
|
||||||
<th>Archived</th>
|
<th>Archived</th>
|
||||||
|
<th>On Website</th>
|
||||||
|
<th>Preferred</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
{% for ap in augmented_parts %}
|
{% for ap in augmented_parts %}
|
||||||
{% include "admin/_augmented_part_row.html" %}
|
{% include "admin/_augmented_part_row.html" %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{{ sort_th("ProductODmm", "OD (mm)", sort_headers) }}
|
{{ sort_th("ProductODmm", "OD (mm)", sort_headers) }}
|
||||||
{{ sort_th("ProductODin", "OD (in)", sort_headers) }}
|
{{ sort_th("ProductODin", "OD (in)", sort_headers) }}
|
||||||
{{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }}
|
{{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }}
|
||||||
|
{{ sort_th("UACDiameterRangemm", "UAC Diameter Range (mm)", sort_headers) }}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -25,9 +26,10 @@
|
|||||||
<td>{{ p.ProductODmm or '—' }}</td>
|
<td>{{ p.ProductODmm or '—' }}</td>
|
||||||
<td>{{ p.ProductODin or '—' }}</td>
|
<td>{{ p.ProductODin or '—' }}</td>
|
||||||
<td>{{ p.TBXThreadAttribute or '—' }}</td>
|
<td>{{ p.TBXThreadAttribute or '—' }}</td>
|
||||||
|
<td>{{ p.UACDiameterRangemm or '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
{% if sync_error %}<p class="alert alert-error">{{ sync_error }}</p>{% endif %}
|
{% if sync_error %}<p class="alert alert-error">{{ sync_error }}</p>{% endif %}
|
||||||
|
|
||||||
{% if any_unlinked %}
|
{% 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"
|
<form hx-post="/admin/augmented-parts/skipped"
|
||||||
hx-target="#skipped-parts-list"
|
hx-target="#skipped-parts-list"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
@@ -29,21 +44,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" %}
|
{% include "admin/_skipped_parts_table_card.html" %}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<span class="tag">{{ ap.part_type.name }}</span>
|
<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.sub_part_type %}<span class="tag">{{ ap.sub_part_type.name }}</span>{% endif %}
|
||||||
{% if ap.archived %}<span class="tag">Archived</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 %}
|
{% if ap.description %}{{ ap.description }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<main class="container">
|
<main class="container">
|
||||||
<h1>Augmented Parts</h1>
|
<h1>Augmented Parts</h1>
|
||||||
|
|
||||||
<div class="card">
|
<details class=card>
|
||||||
<h2>Add part</h2>
|
<summary>Add a part</summary>
|
||||||
<form hx-post="/admin/augmented-parts" hx-target="#augmented-part-list" hx-swap="outerHTML" class="form-grid">
|
<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>SKU <input type="text" name="sku" placeholder="optional"></label>
|
||||||
<label>Description <input type="text" name="description" required></label>
|
<label>Description <input type="text" name="description" required></label>
|
||||||
@@ -50,9 +50,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sync with Imported Parts</h2>
|
<h2>Sync with Imported Parts</h2>
|
||||||
|
|||||||
+64
-8
@@ -728,7 +728,7 @@ async def create_manufacturer(
|
|||||||
created = await manufacturer_crud.create(
|
created = await manufacturer_crud.create(
|
||||||
db,
|
db,
|
||||||
name=name,
|
name=name,
|
||||||
url=url,
|
url=url.strip() or None,
|
||||||
logo_url=logo_url.strip() or None,
|
logo_url=logo_url.strip() or None,
|
||||||
)
|
)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -789,7 +789,11 @@ async def update_manufacturer(
|
|||||||
m = await manufacturer_crud.get(db, manufacturer_id)
|
m = await manufacturer_crud.get(db, manufacturer_id)
|
||||||
if m is None:
|
if m is None:
|
||||||
raise HTTPException(status_code=404, detail="Manufacturer not found")
|
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 manufacturer_crud.update(db, m, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -1043,6 +1047,9 @@ async def update_part_type(
|
|||||||
pt = await part_type_crud.get(db, part_type_id)
|
pt = await part_type_crud.get(db, part_type_id)
|
||||||
if pt is None:
|
if pt is None:
|
||||||
raise HTTPException(status_code=404, detail="Part type not found")
|
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}
|
changes = {"name": name}
|
||||||
await part_type_crud.update(db, pt, changes)
|
await part_type_crud.update(db, pt, changes)
|
||||||
await audit_crud.log(
|
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)
|
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||||
if spt is None:
|
if spt is None:
|
||||||
raise HTTPException(status_code=404, detail="Sub part type not found")
|
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}
|
changes = {"name": name, "part_type_id": part_type_id}
|
||||||
await sub_part_type_crud.update(db, spt, changes)
|
await sub_part_type_crud.update(db, spt, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -1221,6 +1233,7 @@ _IMPORTED_PARTS_SORT_COLUMNS = [
|
|||||||
"ProductODmm",
|
"ProductODmm",
|
||||||
"ProductODin",
|
"ProductODin",
|
||||||
"TBXThreadAttribute",
|
"TBXThreadAttribute",
|
||||||
|
"UACDiameterRangemm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1661,6 +1674,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
|
|||||||
"sort": "description",
|
"sort": "description",
|
||||||
"dir": "asc",
|
"dir": "asc",
|
||||||
"include_archived": False,
|
"include_archived": False,
|
||||||
|
"on_website": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1670,23 +1684,30 @@ async def augmented_parts_page(
|
|||||||
sort: str = "description",
|
sort: str = "description",
|
||||||
dir: str = "asc",
|
dir: str = "asc",
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
on_website: bool = False,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(web_require("augmented_parts:read")),
|
user: User = Depends(web_require("augmented_parts:read")),
|
||||||
):
|
):
|
||||||
ctx = await _augmented_part_form_ctx(db)
|
ctx = await _augmented_part_form_ctx(db)
|
||||||
total = await augmented_part_crud.count_filtered(
|
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(
|
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
|
||||||
db,
|
db,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
direction=dir,
|
direction=dir,
|
||||||
include_archived=include_archived,
|
include_archived=include_archived,
|
||||||
|
on_website=on_website,
|
||||||
limit=_PAGE_SIZE,
|
limit=_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
ctx["pagination"] = _paginate(
|
ctx["pagination"] = _paginate(
|
||||||
"/admin/augmented-parts/filter",
|
"/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,
|
1,
|
||||||
_PAGE_SIZE,
|
_PAGE_SIZE,
|
||||||
total,
|
total,
|
||||||
@@ -1694,7 +1715,7 @@ async def augmented_parts_page(
|
|||||||
)
|
)
|
||||||
ctx["sort_headers"] = _sort_headers(
|
ctx["sort_headers"] = _sort_headers(
|
||||||
"/admin/augmented-parts/filter",
|
"/admin/augmented-parts/filter",
|
||||||
{"include_archived": include_archived},
|
{"include_archived": include_archived, "on_website": on_website},
|
||||||
_AUGMENTED_PARTS_SORT_COLUMNS,
|
_AUGMENTED_PARTS_SORT_COLUMNS,
|
||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
@@ -1703,6 +1724,7 @@ async def augmented_parts_page(
|
|||||||
ctx["sort"] = sort
|
ctx["sort"] = sort
|
||||||
ctx["dir"] = dir
|
ctx["dir"] = dir
|
||||||
ctx["include_archived"] = include_archived
|
ctx["include_archived"] = include_archived
|
||||||
|
ctx["on_website"] = on_website
|
||||||
ctx["user"] = user
|
ctx["user"] = user
|
||||||
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
|
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
|
||||||
|
|
||||||
@@ -1717,18 +1739,25 @@ async def create_augmented_part(
|
|||||||
scope_model_id: str = Form(""),
|
scope_model_id: str = Form(""),
|
||||||
part_type_id: int = Form(...),
|
part_type_id: int = Form(...),
|
||||||
sub_part_type_id: str = Form(""),
|
sub_part_type_id: str = Form(""),
|
||||||
|
on_website: bool = Form(False),
|
||||||
|
preferred_part: bool = Form(False),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(web_require("augmented_parts:write")),
|
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(
|
created = await augmented_part_crud.create(
|
||||||
db,
|
db,
|
||||||
sku=sku.strip() or None,
|
sku=clean_sku,
|
||||||
description=description,
|
description=description,
|
||||||
colour_id=colour_id,
|
colour_id=colour_id,
|
||||||
manufacturer_id=manufacturer_id,
|
manufacturer_id=manufacturer_id,
|
||||||
scope_model_id=int(scope_model_id) if scope_model_id else None,
|
scope_model_id=int(scope_model_id) if scope_model_id else None,
|
||||||
part_type_id=part_type_id,
|
part_type_id=part_type_id,
|
||||||
sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None,
|
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(
|
await audit_crud.log(
|
||||||
db,
|
db,
|
||||||
@@ -1773,7 +1802,8 @@ async def sync_augmented_parts(
|
|||||||
resource_type="AugmentedPart",
|
resource_type="AugmentedPart",
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
description=(
|
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)"
|
f"{result.skipped} skipped (no type match)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1781,6 +1811,7 @@ async def sync_augmented_parts(
|
|||||||
job_id,
|
job_id,
|
||||||
message=(
|
message=(
|
||||||
f"Sync complete: {result.created} created, "
|
f"Sync complete: {result.created} created, "
|
||||||
|
f"{result.updated} updated, "
|
||||||
f"{result.archived} archived"
|
f"{result.archived} archived"
|
||||||
+ (
|
+ (
|
||||||
f", {result.skipped} skipped (type not recognised)"
|
f", {result.skipped} skipped (type not recognised)"
|
||||||
@@ -1856,6 +1887,7 @@ async def filter_augmented_parts(
|
|||||||
part_type_id: str = "",
|
part_type_id: str = "",
|
||||||
sub_part_type_id: str = "",
|
sub_part_type_id: str = "",
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
on_website: bool = False,
|
||||||
sort: str = "description",
|
sort: str = "description",
|
||||||
dir: str = "asc",
|
dir: str = "asc",
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -1877,6 +1909,7 @@ async def filter_augmented_parts(
|
|||||||
part_type_id=part_type_id_int,
|
part_type_id=part_type_id_int,
|
||||||
sub_part_type_id=sub_part_type_id_int,
|
sub_part_type_id=sub_part_type_id_int,
|
||||||
include_archived=include_archived,
|
include_archived=include_archived,
|
||||||
|
on_website=on_website,
|
||||||
)
|
)
|
||||||
filter_params = {
|
filter_params = {
|
||||||
"description": description,
|
"description": description,
|
||||||
@@ -1886,6 +1919,7 @@ async def filter_augmented_parts(
|
|||||||
"part_type_id": part_type_id,
|
"part_type_id": part_type_id,
|
||||||
"sub_part_type_id": sub_part_type_id,
|
"sub_part_type_id": sub_part_type_id,
|
||||||
"include_archived": include_archived,
|
"include_archived": include_archived,
|
||||||
|
"on_website": on_website,
|
||||||
}
|
}
|
||||||
pagination = _paginate(
|
pagination = _paginate(
|
||||||
"/admin/augmented-parts/filter",
|
"/admin/augmented-parts/filter",
|
||||||
@@ -1904,6 +1938,7 @@ async def filter_augmented_parts(
|
|||||||
part_type_id=part_type_id_int,
|
part_type_id=part_type_id_int,
|
||||||
sub_part_type_id=sub_part_type_id_int,
|
sub_part_type_id=sub_part_type_id_int,
|
||||||
include_archived=include_archived,
|
include_archived=include_archived,
|
||||||
|
on_website=on_website,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
direction=dir,
|
direction=dir,
|
||||||
skip=pagination["skip"],
|
skip=pagination["skip"],
|
||||||
@@ -1927,6 +1962,7 @@ async def filter_augmented_parts(
|
|||||||
"sort": sort,
|
"sort": sort,
|
||||||
"dir": dir,
|
"dir": dir,
|
||||||
"include_archived": include_archived,
|
"include_archived": include_archived,
|
||||||
|
"on_website": on_website,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2170,20 +2206,29 @@ async def update_augmented_part(
|
|||||||
scope_model_id: str = Form(""),
|
scope_model_id: str = Form(""),
|
||||||
part_type_id: int = Form(...),
|
part_type_id: int = Form(...),
|
||||||
sub_part_type_id: str = Form(""),
|
sub_part_type_id: str = Form(""),
|
||||||
|
on_website: bool = Form(False),
|
||||||
|
preferred_part: bool = Form(False),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
actor: User = Depends(web_require("augmented_parts:write")),
|
actor: User = Depends(web_require("augmented_parts:write")),
|
||||||
):
|
):
|
||||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||||
if ap is None:
|
if ap is None:
|
||||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
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 = {
|
changes = {
|
||||||
"sku": sku.strip() or None,
|
"sku": clean_sku,
|
||||||
"description": description,
|
"description": description,
|
||||||
"colour_id": colour_id,
|
"colour_id": colour_id,
|
||||||
"manufacturer_id": manufacturer_id,
|
"manufacturer_id": manufacturer_id,
|
||||||
"scope_model_id": int(scope_model_id) if scope_model_id else None,
|
"scope_model_id": int(scope_model_id) if scope_model_id else None,
|
||||||
"part_type_id": part_type_id,
|
"part_type_id": part_type_id,
|
||||||
"sub_part_type_id": int(sub_part_type_id) if sub_part_type_id else None,
|
"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 augmented_part_crud.update(db, ap, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -3061,6 +3106,9 @@ async def update_part_colour(
|
|||||||
pc = await part_colour_crud.get(db, part_colour_id)
|
pc = await part_colour_crud.get(db, part_colour_id)
|
||||||
if pc is None:
|
if pc is None:
|
||||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
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}
|
changes = {"name": name}
|
||||||
await part_colour_crud.update(db, pc, changes)
|
await part_colour_crud.update(db, pc, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -3180,6 +3228,11 @@ async def update_attachment_point_name(
|
|||||||
apn = await apn_crud.get(db, apn_id)
|
apn = await apn_crud.get(db, apn_id)
|
||||||
if apn is None:
|
if apn is None:
|
||||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
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}
|
changes = {"name": name}
|
||||||
await apn_crud.update(db, apn, changes)
|
await apn_crud.update(db, apn, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
@@ -3517,6 +3570,9 @@ async def update_static_connection(
|
|||||||
sc = await static_connection_crud.get(db, static_connection_id)
|
sc = await static_connection_crud.get(db, static_connection_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
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}
|
changes = {"od_mm": od_mm, "od_in": od_in}
|
||||||
await static_connection_crud.update(db, sc, changes)
|
await static_connection_crud.update(db, sc, changes)
|
||||||
await audit_crud.log(
|
await audit_crud.log(
|
||||||
|
|||||||
+3
-1
@@ -234,7 +234,8 @@ async def dashboard_sync_augmented_parts(
|
|||||||
resource_type="AugmentedPart",
|
resource_type="AugmentedPart",
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
description=(
|
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)"
|
f"{result.skipped} skipped (no type match)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -242,6 +243,7 @@ async def dashboard_sync_augmented_parts(
|
|||||||
job_id,
|
job_id,
|
||||||
message=(
|
message=(
|
||||||
f"Sync complete: {result.created} created, "
|
f"Sync complete: {result.created} created, "
|
||||||
|
f"{result.updated} updated, "
|
||||||
f"{result.archived} archived"
|
f"{result.archived} archived"
|
||||||
+ (
|
+ (
|
||||||
f", {result.skipped} skipped (type not recognised)"
|
f", {result.skipped} skipped (type not recognised)"
|
||||||
|
|||||||
@@ -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()]
|
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"):
|
async def _web_login(client, email, password="secret123"):
|
||||||
resp = await client.post("/login", data={"email": email, "password": password})
|
resp = await client.post("/login", data={"email": email, "password": password})
|
||||||
assert resp.status_code == 303
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user