Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81c368663 | ||
|
|
8860c5691f | ||
|
|
054770d370 | ||
|
|
4fd2bb1b58 | ||
|
|
13b64a2d17 | ||
|
|
d23f5b9e82 | ||
|
|
597cf68157 | ||
|
|
d8db87c13e | ||
|
|
af5343da0c | ||
|
|
90cae15551 | ||
|
|
16ba09f073 | ||
|
|
53fe03fb2d | ||
|
|
c414708b32 |
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")
|
||||
@@ -28,12 +28,19 @@ def upgrade() -> None:
|
||||
sa.Column("logo_url", sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# batch_alter_table's alter_column doesn't emit CREATE TYPE for the new
|
||||
# Enum on its own (unlike create_table, which ties enum creation to the
|
||||
# table-create DDL event) - create it explicitly first, or Postgres fails
|
||||
# with `type "uncseries" does not exist`. checkfirst / no-op on SQLite.
|
||||
uncseries = sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries")
|
||||
uncseries.create(op.get_bind(), checkfirst=True)
|
||||
with op.batch_alter_table("unc_threads", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"series",
|
||||
existing_type=sa.VARCHAR(length=8),
|
||||
type_=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||
type_=uncseries,
|
||||
existing_nullable=False,
|
||||
postgresql_using="series::uncseries",
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -47,7 +54,9 @@ def downgrade() -> None:
|
||||
existing_type=sa.Enum("UNC", "UNF", "UNEF", "UN", "UNS", name="uncseries"),
|
||||
type_=sa.VARCHAR(length=8),
|
||||
existing_nullable=False,
|
||||
postgresql_using="series::varchar(8)",
|
||||
)
|
||||
sa.Enum(name="uncseries").drop(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.drop_table("manufacturers")
|
||||
# ### end Alembic commands ###
|
||||
@@ -33,6 +33,10 @@ async def create_attachment_point_name(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("attachment_point_names:write")),
|
||||
):
|
||||
if await apn_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Attachment point name already exists"
|
||||
)
|
||||
apn = await apn_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -68,6 +72,12 @@ async def update_attachment_point_name(
|
||||
if apn is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes:
|
||||
existing = await apn_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != apn.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Attachment point name already exists"
|
||||
)
|
||||
apn = await apn_crud.update(db, apn, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
+28
-12
@@ -20,6 +20,15 @@ from app.services import augmented_part_sync
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_augmented_part_by_identifier(db: AsyncSession, identifier: str):
|
||||
"""Resolve an AugmentedPart by numeric id, falling back to sku."""
|
||||
if identifier.isdigit():
|
||||
ap = await augmented_part_crud.get(db, int(identifier))
|
||||
if ap is not None:
|
||||
return ap
|
||||
return await augmented_part_crud.get_by_sku(db, identifier)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AugmentedPartRead])
|
||||
async def list_augmented_parts(
|
||||
skip: int = 0,
|
||||
@@ -39,6 +48,8 @@ async def create_augmented_part(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
if payload.sku and await augmented_part_crud.get_by_sku(db, payload.sku):
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
ap = await augmented_part_crud.create(
|
||||
db,
|
||||
sku=payload.sku,
|
||||
@@ -48,6 +59,8 @@ async def create_augmented_part(
|
||||
scope_model_id=payload.scope_model_id,
|
||||
part_type_id=payload.part_type_id,
|
||||
sub_part_type_id=payload.sub_part_type_id,
|
||||
on_website=payload.on_website,
|
||||
preferred_part=payload.preferred_part,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -75,8 +88,8 @@ async def sync_augmented_parts(
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Sync: {result.created} created, {result.archived} archived, "
|
||||
f"{result.skipped} skipped (no type match)"
|
||||
f"Sync: {result.created} created, {result.updated} updated, "
|
||||
f"{result.archived} archived, {result.skipped} skipped (no type match)"
|
||||
),
|
||||
)
|
||||
return result
|
||||
@@ -94,17 +107,21 @@ async def get_augmented_part(
|
||||
return ap
|
||||
|
||||
|
||||
@router.patch("/{augmented_part_id}", response_model=AugmentedPartRead)
|
||||
@router.patch("/{identifier}", response_model=AugmentedPartRead)
|
||||
async def update_augmented_part(
|
||||
augmented_part_id: int,
|
||||
identifier: str,
|
||||
payload: AugmentedPartUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:write")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
ap = await _get_augmented_part_by_identifier(db, identifier)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if changes.get("sku"):
|
||||
existing = await augmented_part_crud.get_by_sku(db, changes["sku"])
|
||||
if existing is not None and existing.id != ap.id:
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
ap = await augmented_part_crud.update(db, ap, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -117,15 +134,16 @@ async def update_augmented_part(
|
||||
return ap
|
||||
|
||||
|
||||
@router.delete("/{augmented_part_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_augmented_part(
|
||||
augmented_part_id: int,
|
||||
identifier: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("augmented_parts:delete")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
ap = await _get_augmented_part_by_identifier(db, identifier)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
resolved_id = ap.id
|
||||
label = ap.sku or ap.description
|
||||
await augmented_part_crud.delete(db, ap)
|
||||
await audit_crud.log(
|
||||
@@ -133,10 +151,8 @@ async def delete_augmented_part(
|
||||
actor=actor,
|
||||
action="delete",
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=augmented_part_id,
|
||||
description=audit_crud.describe_delete(
|
||||
"AugmentedPart", augmented_part_id, label
|
||||
),
|
||||
resource_id=resolved_id,
|
||||
description=audit_crud.describe_delete("AugmentedPart", resolved_id, label),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ async def create_part_colour(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_colours:write")),
|
||||
):
|
||||
if await part_colour_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(status_code=409, detail="Part colour already exists")
|
||||
pc = await part_colour_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -62,6 +64,10 @@ async def update_part_colour(
|
||||
if pc is None:
|
||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes:
|
||||
existing = await part_colour_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != pc.id:
|
||||
raise HTTPException(status_code=409, detail="Part colour already exists")
|
||||
pc = await part_colour_crud.update(db, pc, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -27,6 +27,10 @@ async def create_part_type(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("part_types:write")),
|
||||
):
|
||||
if await part_type_crud.get_by_name(db, payload.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
|
||||
)
|
||||
pt = await part_type_crud.create(db, name=payload.name)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -66,6 +70,12 @@ async def update_part_type(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Part type not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes:
|
||||
existing = await part_type_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != pt.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Part type already exists"
|
||||
)
|
||||
pt = await part_type_crud.update(db, pt, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -81,6 +81,12 @@ async def update_role(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes:
|
||||
existing = await role_crud.get_by_name(db, changes["name"])
|
||||
if existing is not None and existing.id != role.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
|
||||
)
|
||||
role = await role_crud.update(db, role, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -33,6 +33,8 @@ async def create_static_connection(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("static_connections:write")),
|
||||
):
|
||||
if await static_connection_crud.get_by_od_mm(db, payload.od_mm):
|
||||
raise HTTPException(status_code=409, detail="Static connection already exists")
|
||||
sc = await static_connection_crud.create(
|
||||
db, od_mm=payload.od_mm, od_in=payload.od_in
|
||||
)
|
||||
@@ -72,6 +74,12 @@ async def update_static_connection(
|
||||
if sc is None:
|
||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "od_mm" in changes:
|
||||
existing = await static_connection_crud.get_by_od_mm(db, changes["od_mm"])
|
||||
if existing is not None and existing.id != sc.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Static connection already exists"
|
||||
)
|
||||
sc = await static_connection_crud.update(db, sc, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -31,6 +31,10 @@ async def create_sub_part_type(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(require_permission("sub_part_types:write")),
|
||||
):
|
||||
if await sub_part_type_crud.get_by_name_and_part_type(
|
||||
db, payload.name, payload.part_type_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Sub part type already exists")
|
||||
spt = await sub_part_type_crud.create(
|
||||
db, name=payload.name, part_type_id=payload.part_type_id
|
||||
)
|
||||
@@ -72,6 +76,14 @@ async def update_sub_part_type(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Sub part type not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "name" in changes or "part_type_id" in changes:
|
||||
name = changes.get("name", spt.name)
|
||||
part_type_id = changes.get("part_type_id", spt.part_type_id)
|
||||
existing = await sub_part_type_crud.get_by_name_and_part_type(
|
||||
db, name, part_type_id
|
||||
)
|
||||
if existing is not None and existing.id != spt.id:
|
||||
raise HTTPException(status_code=409, detail="Sub part type already exists")
|
||||
spt = await sub_part_type_crud.update(db, spt, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -82,6 +82,12 @@ async def update_user(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "email" in changes:
|
||||
existing = await user_crud.get_by_email(db, changes["email"])
|
||||
if existing is not None and existing.id != target.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
)
|
||||
target = await user_crud.update(db, target, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
|
||||
@@ -10,7 +10,14 @@ from app.models.part_type import PartType
|
||||
from app.models.scope_model import ScopeModel
|
||||
from app.models.sub_part_type import SubPartType
|
||||
|
||||
_RELATIONSHIPS = ["colour", "manufacturer", "scope_model", "part_type", "sub_part_type"]
|
||||
_RELATIONSHIPS = [
|
||||
"colour",
|
||||
"manufacturer",
|
||||
"scope_model",
|
||||
"part_type",
|
||||
"sub_part_type",
|
||||
"notes",
|
||||
]
|
||||
|
||||
# Sortable columns for the admin list, keyed by the query-string "sort" value.
|
||||
# Columns backed by a related table's name require an outer join (the FK may be
|
||||
@@ -87,6 +94,7 @@ async def list_filtered(
|
||||
part_type_id: int | None = None,
|
||||
sub_part_type_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
sort: str = "description",
|
||||
direction: str = "asc",
|
||||
skip: int = 0,
|
||||
@@ -110,6 +118,8 @@ async def list_filtered(
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
if not include_archived:
|
||||
q = q.where(AugmentedPart.archived.is_(False))
|
||||
if on_website:
|
||||
q = q.where(AugmentedPart.on_website.is_(True))
|
||||
q = _apply_sort(q, sort, direction)
|
||||
result = await db.execute(q.offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
@@ -124,6 +134,7 @@ async def count_filtered(
|
||||
part_type_id: int | None = None,
|
||||
sub_part_type_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
@@ -145,6 +156,8 @@ async def count_filtered(
|
||||
q = q.where(AugmentedPart.sub_part_type_id == sub_part_type_id)
|
||||
if not include_archived:
|
||||
q = q.where(AugmentedPart.archived.is_(False))
|
||||
if on_website:
|
||||
q = q.where(AugmentedPart.on_website.is_(True))
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -220,6 +233,8 @@ async def create(
|
||||
scope_model_id: int | None,
|
||||
part_type_id: int,
|
||||
sub_part_type_id: int | None,
|
||||
on_website: bool = False,
|
||||
preferred_part: bool = False,
|
||||
) -> AugmentedPart:
|
||||
ap = AugmentedPart(
|
||||
sku=sku,
|
||||
@@ -229,6 +244,8 @@ async def create(
|
||||
scope_model_id=scope_model_id,
|
||||
part_type_id=part_type_id,
|
||||
sub_part_type_id=sub_part_type_id,
|
||||
on_website=on_website,
|
||||
preferred_part=preferred_part,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
|
||||
@@ -22,6 +22,17 @@ async def list_all(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_by_values(
|
||||
db: AsyncSession, *, min_mm: float | None, max_mm: float | None
|
||||
) -> ClampConnection | None:
|
||||
result = await db.execute(
|
||||
select(ClampConnection).where(
|
||||
ClampConnection.min_mm == min_mm, ClampConnection.max_mm == max_mm
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -13,6 +13,7 @@ _FIELDS = frozenset(
|
||||
"ProductODmm",
|
||||
"ProductODin",
|
||||
"TBXThreadAttribute",
|
||||
"UACDiameterRangemm",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,6 +27,7 @@ SORT_COLUMNS = {
|
||||
"ProductODmm": ImportedPart.ProductODmm,
|
||||
"ProductODin": ImportedPart.ProductODin,
|
||||
"TBXThreadAttribute": ImportedPart.TBXThreadAttribute,
|
||||
"UACDiameterRangemm": ImportedPart.UACDiameterRangemm,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
url: str,
|
||||
url: str | None = None,
|
||||
logo_url: str | None = None,
|
||||
) -> Manufacturer:
|
||||
m = Manufacturer(name=name, url=url, logo_url=logo_url)
|
||||
|
||||
@@ -17,7 +17,9 @@ class AugmentedPart(Base):
|
||||
__tablename__ = "augmented_parts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
sku: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
sku: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, unique=True, default=None
|
||||
)
|
||||
description: Mapped[str] = mapped_column(String, nullable=False)
|
||||
colour_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("part_colours.id", ondelete="RESTRICT"), nullable=False
|
||||
@@ -36,10 +38,17 @@ class AugmentedPart(Base):
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
# "##.##-##.##" mm, derived from ImportedPart.UACDiameterRangemm by the sync
|
||||
# service. Read-only — never set outside app/services/augmented_part_sync.py.
|
||||
uac_diameter_range_mm: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, default=None
|
||||
)
|
||||
# Set instead of deleting the row when the last backing ImportedPart is
|
||||
# removed, so fit/attachment-point history is preserved. Hidden from list
|
||||
# views by default.
|
||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
on_website: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
preferred_part: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# linked imported parts — not eagerly loaded; used by sync service only
|
||||
imported_parts: Mapped[list[ImportedPart]] = relationship(
|
||||
|
||||
@@ -19,3 +19,4 @@ class ImportedPart(Base):
|
||||
ProductODmm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
ProductODin: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
TBXThreadAttribute: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
UACDiameterRangemm: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
@@ -8,5 +8,5 @@ class Manufacturer(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(nullable=False)
|
||||
url: Mapped[str] = mapped_column(nullable=False)
|
||||
url: Mapped[str | None] = mapped_column(nullable=True)
|
||||
logo_url: Mapped[str | None] = mapped_column(nullable=True, default=None)
|
||||
@@ -39,6 +39,8 @@ class AugmentedPartBase(BaseModel):
|
||||
scope_model_id: int | None = None
|
||||
part_type_id: int
|
||||
sub_part_type_id: int | None = None
|
||||
on_website: bool = False
|
||||
preferred_part: bool = False
|
||||
|
||||
|
||||
class AugmentedPartCreate(AugmentedPartBase):
|
||||
@@ -54,10 +56,13 @@ class AugmentedPartUpdate(BaseModel):
|
||||
part_type_id: int | None = None
|
||||
sub_part_type_id: int | None = None
|
||||
archived: bool | None = None
|
||||
on_website: bool | None = None
|
||||
preferred_part: bool | None = None
|
||||
|
||||
|
||||
class AugmentedPartSyncResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
archived: int
|
||||
skipped: int
|
||||
|
||||
@@ -67,6 +72,7 @@ class AugmentedPartRead(AugmentedPartBase):
|
||||
|
||||
id: int
|
||||
archived: bool
|
||||
uac_diameter_range_mm: str | None
|
||||
colour: PartColourRead
|
||||
manufacturer: ManufacturerRead
|
||||
scope_model: ScopeModelRead | None
|
||||
|
||||
@@ -12,6 +12,7 @@ class ImportedPartRead(BaseModel):
|
||||
ProductODmm: str | None
|
||||
ProductODin: str | None
|
||||
TBXThreadAttribute: str | None
|
||||
UACDiameterRangemm: str | None
|
||||
|
||||
|
||||
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):
|
||||
name: str
|
||||
url: HttpUrl
|
||||
url: HttpUrl | None = None
|
||||
logo_url: HttpUrl | None = None
|
||||
|
||||
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
|
||||
|
||||
|
||||
class ManufacturerCreate(ManufacturerBase):
|
||||
pass
|
||||
@@ -16,6 +24,8 @@ class ManufacturerUpdate(BaseModel):
|
||||
url: HttpUrl | None = None
|
||||
logo_url: HttpUrl | None = None
|
||||
|
||||
_normalize_url = field_validator("url", "logo_url", mode="before")(_blank_to_none)
|
||||
|
||||
|
||||
class ManufacturerRead(ManufacturerBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -9,7 +9,24 @@ Phase 1 – archive stale entries
|
||||
remaining associations (i.e. it was created purely from now-deleted
|
||||
ImportedParts), preserving its fit/attachment-point history.
|
||||
|
||||
Phase 2 – create entries for new ImportedParts
|
||||
Phase 2 – refresh entries for already-linked ImportedParts
|
||||
• For each ImportedPart already linked to an AugmentedPart (from a prior
|
||||
sync run), refresh the fields sync itself owns — description, colour,
|
||||
and uac_diameter_range_mm (see field mapping below) — to match the
|
||||
ImportedPart's current values.
|
||||
• Never touches "extra"/admin-owned AugmentedPart fields: part_type_id,
|
||||
sub_part_type_id, scope_model_id, manufacturer_id, sku, archived. Those
|
||||
are only ever set once, at creation (phase 3) or via the admin API/UI.
|
||||
• Adds an "Ocular" and/or "Objective" attachment point if the part doesn't
|
||||
already have one of that name and the current ImportedPart data now
|
||||
supports deriving one. Never deletes or modifies an existing attachment
|
||||
point, even if the source data that produced it has since changed.
|
||||
• If an AugmentedPart has more than one backing ImportedPart, the one with
|
||||
the lexicographically smallest InventoryID is used as the source (this
|
||||
doesn't happen via any current creation path, but the data model and
|
||||
phase 1's archiving logic both allow for it).
|
||||
|
||||
Phase 3 – create entries for new ImportedParts
|
||||
• Any ImportedPart not yet linked to an AugmentedPart gets one created.
|
||||
• Description ← ImportedPart.Description (fallback: InventoryID)
|
||||
• Colour ← ImportedPart.TBXColour (fallback: "Unknown"; created if needed)
|
||||
@@ -25,6 +42,12 @@ Phase 2 – create entries for new ImportedParts
|
||||
unit is missing is derived via mm/in conversion) if the
|
||||
thread attribute is absent or unparseable
|
||||
Skipped entirely if none of those fields are usable.
|
||||
UAC diameter range ← ImportedPart.UACDiameterRangemm, an 8-digit string with
|
||||
no separators (2 leading digits + 2 decimal digits for the
|
||||
minimum, then the same for the maximum) reformatted as
|
||||
AugmentedPart.uac_diameter_range_mm = "##.##-##.##". When
|
||||
present, also adds a second attachment point named
|
||||
"Objective" with a ClampConnection spanning that range.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -34,6 +57,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import attachment_point as attachment_point_crud
|
||||
from app.crud import attachment_point_name as attachment_point_name_crud
|
||||
from app.crud import augmented_part as augmented_part_crud
|
||||
from app.crud import clamp_connection as clamp_connection_crud
|
||||
from app.crud import iso_thread as iso_thread_crud
|
||||
from app.crud import manufacturer as manufacturer_crud
|
||||
@@ -49,6 +73,7 @@ from app.models.unc_thread import UncSeries
|
||||
from app.schemas.augmented_part import AugmentedPartSyncResult
|
||||
|
||||
ATTACHMENT_POINT_NAME = "Ocular"
|
||||
UAC_ATTACHMENT_POINT_NAME = "Objective"
|
||||
MM_PER_INCH = 25.4
|
||||
|
||||
# e.g.
|
||||
@@ -118,6 +143,49 @@ def _parse_thread_attribute(value: str) -> tuple[str, dict] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_uac_diameter_range(value: str | None) -> tuple[str, float, float] | None:
|
||||
"""Parse an 8-digit UACDiameterRangemm value into ("##.##-##.##", min, max).
|
||||
|
||||
The source field is 8 digits with no separators: the first 4 are the
|
||||
minimum diameter (2 whole + 2 decimal), the last 4 the maximum, e.g.
|
||||
"03505500" -> ("03.50-55.00", 3.5, 55.0).
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
digits = "".join(re.findall(r"\d", value))
|
||||
if len(digits) != 8:
|
||||
return None
|
||||
min_str = f"{digits[0:2]}.{digits[2:4]}"
|
||||
max_str = f"{digits[4:6]}.{digits[6:8]}"
|
||||
return f"{min_str}-{max_str}", float(min_str), float(max_str)
|
||||
|
||||
|
||||
async def _add_uac_clamp_attachment_point(
|
||||
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
|
||||
) -> None:
|
||||
"""Add a second, "Objective"-named clamp attachment point derived from
|
||||
ImportedPart.UACDiameterRangemm, if that field is populated."""
|
||||
parsed = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||
if parsed is None:
|
||||
return
|
||||
_, min_mm, max_mm = parsed
|
||||
min_in = min_mm / MM_PER_INCH
|
||||
max_in = max_mm / MM_PER_INCH
|
||||
|
||||
apn = await _get_or_create_attachment_point_name(db, UAC_ATTACHMENT_POINT_NAME)
|
||||
clamp = await clamp_connection_crud.get_by_values(db, min_mm=min_mm, max_mm=max_mm)
|
||||
if clamp is None:
|
||||
clamp = await clamp_connection_crud.create(
|
||||
db, min_mm=min_mm, max_mm=max_mm, min_in=min_in, max_in=max_in
|
||||
)
|
||||
await attachment_point_crud.create(
|
||||
db,
|
||||
augmented_part_id=ap.id,
|
||||
attachment_point_name_id=apn.id,
|
||||
clamp_connection_id=clamp.id,
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_attachment_point_name(db: AsyncSession, name: str):
|
||||
apn = await attachment_point_name_crud.get_by_name(db, name)
|
||||
if apn is None:
|
||||
@@ -233,6 +301,8 @@ async def sync_one(
|
||||
if manufacturer is None:
|
||||
manufacturer = await manufacturer_crud.create(db, name="Armament", url="")
|
||||
|
||||
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||
|
||||
ap = AugmentedPart(
|
||||
sku=ip.InventoryID,
|
||||
description=description,
|
||||
@@ -241,6 +311,7 @@ async def sync_one(
|
||||
scope_model_id=None,
|
||||
part_type_id=part_type_id,
|
||||
sub_part_type_id=sub_part_type_id,
|
||||
uac_diameter_range_mm=uac_range[0] if uac_range else None,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.flush()
|
||||
@@ -252,6 +323,7 @@ async def sync_one(
|
||||
)
|
||||
)
|
||||
await _add_attachment_point(db, ap, ip)
|
||||
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||
await db.commit()
|
||||
return ap
|
||||
|
||||
@@ -354,6 +426,52 @@ async def delete_imported_part_and_archive_orphans(
|
||||
return archived
|
||||
|
||||
|
||||
async def _refresh_augmented_part(
|
||||
db: AsyncSession, ap: AugmentedPart, ip: ImportedPart
|
||||
) -> bool:
|
||||
"""Refresh the AugmentedPart fields sync owns (description, colour, UAC
|
||||
diameter range) from the current state of `ip`, and add an "Ocular"
|
||||
and/or "Objective" attachment point if the part doesn't already have one
|
||||
and the current data now supports deriving one.
|
||||
|
||||
Never touches "extra" AugmentedPart fields (part_type_id, sub_part_type_id,
|
||||
scope_model_id, manufacturer_id, sku, archived) and never deletes or
|
||||
replaces an attachment point that already exists. Returns True if
|
||||
anything changed.
|
||||
"""
|
||||
description = (ip.Description or "").strip() or ip.InventoryID
|
||||
|
||||
colour_name = (ip.TBXColour or "").strip() or "Unknown"
|
||||
colour = await part_colour_crud.get_by_name(db, colour_name)
|
||||
if colour is None:
|
||||
colour = await part_colour_crud.create(db, name=colour_name)
|
||||
|
||||
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||
uac_value = uac_range[0] if uac_range else None
|
||||
|
||||
changes = {}
|
||||
if ap.description != description:
|
||||
changes["description"] = description
|
||||
if ap.colour_id != colour.id:
|
||||
changes["colour_id"] = colour.id
|
||||
if ap.uac_diameter_range_mm != uac_value:
|
||||
changes["uac_diameter_range_mm"] = uac_value
|
||||
if changes:
|
||||
await augmented_part_crud.update(db, ap, changes)
|
||||
|
||||
existing_points = await attachment_point_crud.list_for_augmented_part(db, ap.id)
|
||||
existing_names = {p.attachment_point_name.name for p in existing_points}
|
||||
points_before = len(existing_points)
|
||||
|
||||
if ATTACHMENT_POINT_NAME not in existing_names:
|
||||
await _add_attachment_point(db, ap, ip)
|
||||
if UAC_ATTACHMENT_POINT_NAME not in existing_names:
|
||||
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||
|
||||
points_after = await attachment_point_crud.list_for_augmented_part(db, ap.id)
|
||||
return bool(changes) or len(points_after) > points_before
|
||||
|
||||
|
||||
async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||
# ------------------------------------------------------------------ phase 1
|
||||
|
||||
@@ -376,19 +494,45 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||
|
||||
# ------------------------------------------------------------------ phase 2
|
||||
|
||||
# Re-query linked IDs after phase 1 cleanup
|
||||
# Re-query linked IDs after phase 1 cleanup — these are the ImportedParts
|
||||
# already linked to an AugmentedPart from a prior run (not created below).
|
||||
result = await db.execute(
|
||||
select(augmented_part_imported_parts.c.imported_part_id).distinct()
|
||||
)
|
||||
now_linked_ids: set[str] = {row[0] for row in result.all()}
|
||||
unlinked_ids = current_ids - now_linked_ids
|
||||
|
||||
updated = 0
|
||||
|
||||
if now_linked_ids:
|
||||
result = await db.execute(
|
||||
select(
|
||||
augmented_part_imported_parts.c.augmented_part_id,
|
||||
augmented_part_imported_parts.c.imported_part_id,
|
||||
).where(
|
||||
augmented_part_imported_parts.c.imported_part_id.in_(now_linked_ids)
|
||||
)
|
||||
)
|
||||
ip_ids_by_ap_id: dict[int, list[str]] = {}
|
||||
for ap_id, ip_id in result.all():
|
||||
ip_ids_by_ap_id.setdefault(ap_id, []).append(ip_id)
|
||||
|
||||
for ap_id, ip_ids in ip_ids_by_ap_id.items():
|
||||
ap = await db.get(AugmentedPart, ap_id)
|
||||
ip = await db.get(ImportedPart, min(ip_ids))
|
||||
if ap is None or ip is None:
|
||||
continue
|
||||
if await _refresh_augmented_part(db, ap, ip):
|
||||
updated += 1
|
||||
|
||||
# ------------------------------------------------------------------ phase 3
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
|
||||
if not unlinked_ids:
|
||||
return AugmentedPartSyncResult(
|
||||
created=created, archived=archived, skipped=skipped
|
||||
created=created, updated=updated, archived=archived, skipped=skipped
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
@@ -416,6 +560,8 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||
if colour is None:
|
||||
colour = await part_colour_crud.create(db, name=colour_name)
|
||||
|
||||
uac_range = _parse_uac_diameter_range(ip.UACDiameterRangemm)
|
||||
|
||||
ap = AugmentedPart(
|
||||
sku=ip.InventoryID,
|
||||
description=description,
|
||||
@@ -424,6 +570,7 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||
scope_model_id=None,
|
||||
part_type_id=part_type.id,
|
||||
sub_part_type_id=None,
|
||||
uac_diameter_range_mm=uac_range[0] if uac_range else None,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.flush()
|
||||
@@ -435,8 +582,11 @@ async def sync(db: AsyncSession) -> AugmentedPartSyncResult:
|
||||
)
|
||||
)
|
||||
await _add_attachment_point(db, ap, ip)
|
||||
await _add_uac_clamp_attachment_point(db, ap, ip)
|
||||
created += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return AugmentedPartSyncResult(created=created, archived=archived, skipped=skipped)
|
||||
return AugmentedPartSyncResult(
|
||||
created=created, updated=updated, archived=archived, skipped=skipped
|
||||
)
|
||||
@@ -145,11 +145,15 @@ def parse_workbook(data: bytes) -> list[dict]:
|
||||
wb.close()
|
||||
|
||||
|
||||
async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float):
|
||||
async def _static_ap(
|
||||
db: AsyncSession, *, augmented_part_id: int, label: str, od_mm: float
|
||||
):
|
||||
"""Create an attachment point carrying a StaticConnection for ``od_mm``."""
|
||||
sc = await static_connection_crud.get_by_od_mm(db, od_mm)
|
||||
if sc is None:
|
||||
sc = await static_connection_crud.create(db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH)
|
||||
sc = await static_connection_crud.create(
|
||||
db, od_mm=od_mm, od_in=od_mm / MM_PER_INCH
|
||||
)
|
||||
apn = await apn_crud.get_by_name(db, label)
|
||||
if apn is None:
|
||||
apn = await apn_crud.create(db, name=label)
|
||||
@@ -161,7 +165,9 @@ async def _static_ap(db: AsyncSession, *, augmented_part_id: int, label: str, od
|
||||
)
|
||||
|
||||
|
||||
async def _thread_ap(db: AsyncSession, *, augmented_part_id: int, label: str, spec: str):
|
||||
async def _thread_ap(
|
||||
db: AsyncSession, *, augmented_part_id: int, label: str, spec: str
|
||||
):
|
||||
"""Create an attachment point for a parsed ISO/UNC thread, or None."""
|
||||
parsed = _parse_thread_attribute(spec)
|
||||
if parsed is None:
|
||||
@@ -268,14 +274,18 @@ async def _link_fit(
|
||||
if target_aps:
|
||||
target_ap = target_aps[0]
|
||||
else:
|
||||
target_ap = await _mirrored_ap(db, augmented_part_id=target.id, scope_ap=scope_ap)
|
||||
target_ap = await _mirrored_ap(
|
||||
db, augmented_part_id=target.id, scope_ap=scope_ap
|
||||
)
|
||||
messages.append(
|
||||
f"{scope_role}_fit '{target_sku}': referenced part had no attachment points, "
|
||||
"created one matching the scope's attachment point"
|
||||
)
|
||||
existing = await fit_crud.get_by_attachment_points(db, scope_ap, target_ap)
|
||||
if existing is None:
|
||||
await fit_crud.create(db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE)
|
||||
await fit_crud.create(
|
||||
db, ap_a=scope_ap, ap_b=target_ap, label=FitLabel.POSSIBLE
|
||||
)
|
||||
|
||||
|
||||
async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult:
|
||||
@@ -300,9 +310,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
|
||||
model = _text(row.get("model"))
|
||||
magnification_min = _num(row.get("magnification_min"))
|
||||
magnification_max = _num(row.get("magnification_max"))
|
||||
objective_lens_diameter_mm = _num(row.get("objective_lens_diameter_mm")) or _num(
|
||||
row.get("objective_lens")
|
||||
)
|
||||
objective_lens_diameter_mm = _num(
|
||||
row.get("objective_lens_diameter_mm")
|
||||
) or _num(row.get("objective_lens"))
|
||||
|
||||
# Construct the model number
|
||||
default_product_number = model
|
||||
@@ -333,7 +343,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
|
||||
|
||||
manufacturer = await manufacturer_crud.get_by_name(db, manufacturer_name)
|
||||
if manufacturer is None:
|
||||
manufacturer = await manufacturer_crud.create(db, name=manufacturer_name, url="")
|
||||
manufacturer = await manufacturer_crud.create(
|
||||
db, name=manufacturer_name, url=""
|
||||
)
|
||||
|
||||
model_name = _text(row.get("model"))
|
||||
url = _text(row.get("url"))
|
||||
@@ -395,7 +407,9 @@ async def import_scopes(db: AsyncSession, rows: list[dict]) -> ScopeImportResult
|
||||
|
||||
# Rebuild attachment points from scratch so re-imports stay idempotent
|
||||
# (cascades delete any fits that referenced the old points).
|
||||
for old_ap in await attachment_point_crud.list_for_augmented_part(db, part.id):
|
||||
for old_ap in await attachment_point_crud.list_for_augmented_part(
|
||||
db, part.id
|
||||
):
|
||||
await attachment_point_crud.delete(db, old_ap)
|
||||
|
||||
ocular_ap = None
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<td>{{ ap.part_type.name }}</td>
|
||||
<td>{{ ap.sub_part_type.name if ap.sub_part_type else '—' }}</td>
|
||||
<td>{% if ap.archived %}<span class="tag">Archived</span>{% else %}—{% endif %}</td>
|
||||
<td>{% if ap.on_website %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
|
||||
<td>{% if ap.preferred_part %}<span class="tag">Yes</span>{% else %}—{% endif %}</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn btn-ghost"
|
||||
hx-get="/admin/augmented-parts/{{ ap.id }}/edit"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<tr id="augmented-part-{{ ap.id }}">
|
||||
<td colspan="10">
|
||||
<td colspan="12">
|
||||
<form id="augmented-part-edit-form-{{ ap.id }}"
|
||||
hx-post="/admin/augmented-parts/{{ ap.id }}"
|
||||
hx-target="#augmented-part-{{ ap.id }}"
|
||||
@@ -45,6 +45,8 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="on_website" value="true" {{ "checked" if ap.on_website }}> On website</label>
|
||||
<label class="check"><input type="checkbox" name="preferred_part" value="true" {{ "checked" if ap.preferred_part }}> Preferred part</label>
|
||||
<div class="row-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-ghost"
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label><input type="checkbox" name="include_archived" {{ "checked" if include_archived }}> Show archived</label>
|
||||
<label><input type="checkbox" name="on_website" {{ "checked" if on_website }}> On website</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
{{ sort_th("part_type", "Type", sort_headers) }}
|
||||
{{ sort_th("sub_part_type", "Sub Type", sort_headers) }}
|
||||
<th>Archived</th>
|
||||
<th>On Website</th>
|
||||
<th>Preferred</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -20,7 +22,7 @@
|
||||
{% for ap in augmented_parts %}
|
||||
{% include "admin/_augmented_part_row.html" %}
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="muted">No augmented parts recorded.</td></tr>
|
||||
<tr><td colspan="12" class="muted">No augmented parts recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{{ sort_th("ProductODmm", "OD (mm)", sort_headers) }}
|
||||
{{ sort_th("ProductODin", "OD (in)", sort_headers) }}
|
||||
{{ sort_th("TBXThreadAttribute", "Thread Attribute", sort_headers) }}
|
||||
{{ sort_th("UACDiameterRangemm", "UAC Diameter Range (mm)", sort_headers) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -25,9 +26,10 @@
|
||||
<td>{{ p.ProductODmm or '—' }}</td>
|
||||
<td>{{ p.ProductODin or '—' }}</td>
|
||||
<td>{{ p.TBXThreadAttribute or '—' }}</td>
|
||||
<td>{{ p.UACDiameterRangemm or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">No imported parts. Use Refresh to sync from OData.</td></tr>
|
||||
<tr><td colspan="9" class="muted">No imported parts. Use Refresh to sync from OData.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
{% if sync_error %}<p class="alert alert-error">{{ sync_error }}</p>{% endif %}
|
||||
|
||||
{% if any_unlinked %}
|
||||
<div class="filter-bar">
|
||||
<form hx-get="/admin/augmented-parts/skipped/filter"
|
||||
hx-target="#skipped-parts-table-card"
|
||||
hx-trigger="input delay:300ms, change"
|
||||
hx-swap="outerHTML">
|
||||
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
|
||||
<select name="colour">
|
||||
<option value="">All colours</option>
|
||||
{% for c in skipped_colours %}
|
||||
<option value="{{ c }}" {{ "selected" if filter_colour == c }}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form hx-post="/admin/augmented-parts/skipped"
|
||||
hx-target="#skipped-parts-list"
|
||||
hx-swap="outerHTML">
|
||||
@@ -29,21 +44,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form hx-get="/admin/augmented-parts/skipped/filter"
|
||||
hx-target="#skipped-parts-table-card"
|
||||
hx-trigger="input delay:300ms, change"
|
||||
hx-swap="outerHTML">
|
||||
<input type="text" name="description" placeholder="Filter by description…" value="{{ filter_description or '' }}">
|
||||
<select name="colour">
|
||||
<option value="">All colours</option>
|
||||
{% for c in skipped_colours %}
|
||||
<option value="{{ c }}" {{ "selected" if filter_colour == c }}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% include "admin/_skipped_parts_table_card.html" %}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<span class="tag">{{ ap.part_type.name }}</span>
|
||||
{% if ap.sub_part_type %}<span class="tag">{{ ap.sub_part_type.name }}</span>{% endif %}
|
||||
{% if ap.archived %}<span class="tag">Archived</span>{% endif %}
|
||||
{% if ap.on_website %}<span class="tag">On website</span>{% endif %}
|
||||
{% if ap.preferred_part %}<span class="tag">Preferred</span>{% endif %}
|
||||
{% if ap.description %}{{ ap.description }}{% endif %}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<main class="container">
|
||||
<h1>Augmented Parts</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Add part</h2>
|
||||
<details class=card>
|
||||
<summary>Add a part</summary>
|
||||
<form hx-post="/admin/augmented-parts" hx-target="#augmented-part-list" hx-swap="outerHTML" class="form-grid">
|
||||
<label>SKU <input type="text" name="sku" placeholder="optional"></label>
|
||||
<label>Description <input type="text" name="description" required></label>
|
||||
@@ -50,9 +50,11 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" name="on_website" value="true"> On website</label>
|
||||
<label class="check"><input type="checkbox" name="preferred_part" value="true"> Preferred part</label>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync with Imported Parts</h2>
|
||||
|
||||
+64
-8
@@ -728,7 +728,7 @@ async def create_manufacturer(
|
||||
created = await manufacturer_crud.create(
|
||||
db,
|
||||
name=name,
|
||||
url=url,
|
||||
url=url.strip() or None,
|
||||
logo_url=logo_url.strip() or None,
|
||||
)
|
||||
await audit_crud.log(
|
||||
@@ -789,7 +789,11 @@ async def update_manufacturer(
|
||||
m = await manufacturer_crud.get(db, manufacturer_id)
|
||||
if m is None:
|
||||
raise HTTPException(status_code=404, detail="Manufacturer not found")
|
||||
changes = {"name": name, "url": url, "logo_url": logo_url.strip() or None}
|
||||
changes = {
|
||||
"name": name,
|
||||
"url": url.strip() or None,
|
||||
"logo_url": logo_url.strip() or None,
|
||||
}
|
||||
await manufacturer_crud.update(db, m, changes)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -1043,6 +1047,9 @@ async def update_part_type(
|
||||
pt = await part_type_crud.get(db, part_type_id)
|
||||
if pt is None:
|
||||
raise HTTPException(status_code=404, detail="Part type not found")
|
||||
existing = await part_type_crud.get_by_name(db, name)
|
||||
if existing is not None and existing.id != pt.id:
|
||||
raise HTTPException(status_code=409, detail="Part type already exists")
|
||||
changes = {"name": name}
|
||||
await part_type_crud.update(db, pt, changes)
|
||||
await audit_crud.log(
|
||||
@@ -1170,6 +1177,11 @@ async def update_sub_part_type(
|
||||
spt = await sub_part_type_crud.get(db, sub_part_type_id)
|
||||
if spt is None:
|
||||
raise HTTPException(status_code=404, detail="Sub part type not found")
|
||||
existing = await sub_part_type_crud.get_by_name_and_part_type(
|
||||
db, name, part_type_id
|
||||
)
|
||||
if existing is not None and existing.id != spt.id:
|
||||
raise HTTPException(status_code=409, detail="Sub part type already exists")
|
||||
changes = {"name": name, "part_type_id": part_type_id}
|
||||
await sub_part_type_crud.update(db, spt, changes)
|
||||
await audit_crud.log(
|
||||
@@ -1221,6 +1233,7 @@ _IMPORTED_PARTS_SORT_COLUMNS = [
|
||||
"ProductODmm",
|
||||
"ProductODin",
|
||||
"TBXThreadAttribute",
|
||||
"UACDiameterRangemm",
|
||||
]
|
||||
|
||||
|
||||
@@ -1661,6 +1674,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict:
|
||||
"sort": "description",
|
||||
"dir": "asc",
|
||||
"include_archived": False,
|
||||
"on_website": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -1670,23 +1684,30 @@ async def augmented_parts_page(
|
||||
sort: str = "description",
|
||||
dir: str = "asc",
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(web_require("augmented_parts:read")),
|
||||
):
|
||||
ctx = await _augmented_part_form_ctx(db)
|
||||
total = await augmented_part_crud.count_filtered(
|
||||
db, include_archived=include_archived
|
||||
db, include_archived=include_archived, on_website=on_website
|
||||
)
|
||||
ctx["augmented_parts"] = await augmented_part_crud.list_filtered(
|
||||
db,
|
||||
sort=sort,
|
||||
direction=dir,
|
||||
include_archived=include_archived,
|
||||
on_website=on_website,
|
||||
limit=_PAGE_SIZE,
|
||||
)
|
||||
ctx["pagination"] = _paginate(
|
||||
"/admin/augmented-parts/filter",
|
||||
{"sort": sort, "dir": dir, "include_archived": include_archived},
|
||||
{
|
||||
"sort": sort,
|
||||
"dir": dir,
|
||||
"include_archived": include_archived,
|
||||
"on_website": on_website,
|
||||
},
|
||||
1,
|
||||
_PAGE_SIZE,
|
||||
total,
|
||||
@@ -1694,7 +1715,7 @@ async def augmented_parts_page(
|
||||
)
|
||||
ctx["sort_headers"] = _sort_headers(
|
||||
"/admin/augmented-parts/filter",
|
||||
{"include_archived": include_archived},
|
||||
{"include_archived": include_archived, "on_website": on_website},
|
||||
_AUGMENTED_PARTS_SORT_COLUMNS,
|
||||
sort,
|
||||
dir,
|
||||
@@ -1703,6 +1724,7 @@ async def augmented_parts_page(
|
||||
ctx["sort"] = sort
|
||||
ctx["dir"] = dir
|
||||
ctx["include_archived"] = include_archived
|
||||
ctx["on_website"] = on_website
|
||||
ctx["user"] = user
|
||||
return templates.TemplateResponse(request, "admin/augmented_parts.html", ctx)
|
||||
|
||||
@@ -1717,18 +1739,25 @@ async def create_augmented_part(
|
||||
scope_model_id: str = Form(""),
|
||||
part_type_id: int = Form(...),
|
||||
sub_part_type_id: str = Form(""),
|
||||
on_website: bool = Form(False),
|
||||
preferred_part: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(web_require("augmented_parts:write")),
|
||||
):
|
||||
clean_sku = sku.strip() or None
|
||||
if clean_sku and await augmented_part_crud.get_by_sku(db, clean_sku):
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
created = await augmented_part_crud.create(
|
||||
db,
|
||||
sku=sku.strip() or None,
|
||||
sku=clean_sku,
|
||||
description=description,
|
||||
colour_id=colour_id,
|
||||
manufacturer_id=manufacturer_id,
|
||||
scope_model_id=int(scope_model_id) if scope_model_id else None,
|
||||
part_type_id=part_type_id,
|
||||
sub_part_type_id=int(sub_part_type_id) if sub_part_type_id else None,
|
||||
on_website=on_website,
|
||||
preferred_part=preferred_part,
|
||||
)
|
||||
await audit_crud.log(
|
||||
db,
|
||||
@@ -1773,7 +1802,8 @@ async def sync_augmented_parts(
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Sync: {result.created} created, {result.archived} archived, "
|
||||
f"Sync: {result.created} created, {result.updated} updated, "
|
||||
f"{result.archived} archived, "
|
||||
f"{result.skipped} skipped (no type match)"
|
||||
),
|
||||
)
|
||||
@@ -1781,6 +1811,7 @@ async def sync_augmented_parts(
|
||||
job_id,
|
||||
message=(
|
||||
f"Sync complete: {result.created} created, "
|
||||
f"{result.updated} updated, "
|
||||
f"{result.archived} archived"
|
||||
+ (
|
||||
f", {result.skipped} skipped (type not recognised)"
|
||||
@@ -1856,6 +1887,7 @@ async def filter_augmented_parts(
|
||||
part_type_id: str = "",
|
||||
sub_part_type_id: str = "",
|
||||
include_archived: bool = False,
|
||||
on_website: bool = False,
|
||||
sort: str = "description",
|
||||
dir: str = "asc",
|
||||
page: int = 1,
|
||||
@@ -1877,6 +1909,7 @@ async def filter_augmented_parts(
|
||||
part_type_id=part_type_id_int,
|
||||
sub_part_type_id=sub_part_type_id_int,
|
||||
include_archived=include_archived,
|
||||
on_website=on_website,
|
||||
)
|
||||
filter_params = {
|
||||
"description": description,
|
||||
@@ -1886,6 +1919,7 @@ async def filter_augmented_parts(
|
||||
"part_type_id": part_type_id,
|
||||
"sub_part_type_id": sub_part_type_id,
|
||||
"include_archived": include_archived,
|
||||
"on_website": on_website,
|
||||
}
|
||||
pagination = _paginate(
|
||||
"/admin/augmented-parts/filter",
|
||||
@@ -1904,6 +1938,7 @@ async def filter_augmented_parts(
|
||||
part_type_id=part_type_id_int,
|
||||
sub_part_type_id=sub_part_type_id_int,
|
||||
include_archived=include_archived,
|
||||
on_website=on_website,
|
||||
sort=sort,
|
||||
direction=dir,
|
||||
skip=pagination["skip"],
|
||||
@@ -1927,6 +1962,7 @@ async def filter_augmented_parts(
|
||||
"sort": sort,
|
||||
"dir": dir,
|
||||
"include_archived": include_archived,
|
||||
"on_website": on_website,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2170,20 +2206,29 @@ async def update_augmented_part(
|
||||
scope_model_id: str = Form(""),
|
||||
part_type_id: int = Form(...),
|
||||
sub_part_type_id: str = Form(""),
|
||||
on_website: bool = Form(False),
|
||||
preferred_part: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(web_require("augmented_parts:write")),
|
||||
):
|
||||
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status_code=404, detail="Augmented part not found")
|
||||
clean_sku = sku.strip() or None
|
||||
if clean_sku:
|
||||
existing = await augmented_part_crud.get_by_sku(db, clean_sku)
|
||||
if existing is not None and existing.id != ap.id:
|
||||
raise HTTPException(status_code=409, detail="SKU already in use")
|
||||
changes = {
|
||||
"sku": sku.strip() or None,
|
||||
"sku": clean_sku,
|
||||
"description": description,
|
||||
"colour_id": colour_id,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
"scope_model_id": int(scope_model_id) if scope_model_id else None,
|
||||
"part_type_id": part_type_id,
|
||||
"sub_part_type_id": int(sub_part_type_id) if sub_part_type_id else None,
|
||||
"on_website": on_website,
|
||||
"preferred_part": preferred_part,
|
||||
}
|
||||
await augmented_part_crud.update(db, ap, changes)
|
||||
await audit_crud.log(
|
||||
@@ -3061,6 +3106,9 @@ async def update_part_colour(
|
||||
pc = await part_colour_crud.get(db, part_colour_id)
|
||||
if pc is None:
|
||||
raise HTTPException(status_code=404, detail="Part colour not found")
|
||||
existing = await part_colour_crud.get_by_name(db, name)
|
||||
if existing is not None and existing.id != pc.id:
|
||||
raise HTTPException(status_code=409, detail="Part colour already exists")
|
||||
changes = {"name": name}
|
||||
await part_colour_crud.update(db, pc, changes)
|
||||
await audit_crud.log(
|
||||
@@ -3180,6 +3228,11 @@ async def update_attachment_point_name(
|
||||
apn = await apn_crud.get(db, apn_id)
|
||||
if apn is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment point name not found")
|
||||
existing = await apn_crud.get_by_name(db, name)
|
||||
if existing is not None and existing.id != apn.id:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Attachment point name already exists"
|
||||
)
|
||||
changes = {"name": name}
|
||||
await apn_crud.update(db, apn, changes)
|
||||
await audit_crud.log(
|
||||
@@ -3517,6 +3570,9 @@ async def update_static_connection(
|
||||
sc = await static_connection_crud.get(db, static_connection_id)
|
||||
if sc is None:
|
||||
raise HTTPException(status_code=404, detail="Static connection not found")
|
||||
existing = await static_connection_crud.get_by_od_mm(db, od_mm)
|
||||
if existing is not None and existing.id != sc.id:
|
||||
raise HTTPException(status_code=409, detail="Static connection already exists")
|
||||
changes = {"od_mm": od_mm, "od_in": od_in}
|
||||
await static_connection_crud.update(db, sc, changes)
|
||||
await audit_crud.log(
|
||||
|
||||
+3
-1
@@ -234,7 +234,8 @@ async def dashboard_sync_augmented_parts(
|
||||
resource_type="AugmentedPart",
|
||||
resource_id=None,
|
||||
description=(
|
||||
f"Sync: {result.created} created, {result.archived} archived, "
|
||||
f"Sync: {result.created} created, {result.updated} updated, "
|
||||
f"{result.archived} archived, "
|
||||
f"{result.skipped} skipped (no type match)"
|
||||
),
|
||||
)
|
||||
@@ -242,6 +243,7 @@ async def dashboard_sync_augmented_parts(
|
||||
job_id,
|
||||
message=(
|
||||
f"Sync complete: {result.created} created, "
|
||||
f"{result.updated} updated, "
|
||||
f"{result.archived} archived"
|
||||
+ (
|
||||
f", {result.skipped} skipped (type not recognised)"
|
||||
|
||||
@@ -210,6 +210,86 @@ async def test_patch_can_unarchive_an_augmented_part(
|
||||
assert ap_id in [p["id"] for p in default_list.json()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_and_delete_accept_sku_as_identifier(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
ap = AugmentedPart(
|
||||
description="Sku addressed part",
|
||||
sku="SKU-123",
|
||||
colour_id=colour.id,
|
||||
manufacturer_id=manufacturer.id,
|
||||
part_type_id=part_type.id,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
ap_id = ap.id
|
||||
|
||||
await make_user("root3@example.com", is_superuser=True)
|
||||
headers = {"Authorization": f"Bearer {await _token(client, 'root3@example.com')}"}
|
||||
|
||||
resp = await client.patch(
|
||||
"/api/augmented-parts/SKU-123",
|
||||
json={"description": "Renamed via sku"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == ap_id
|
||||
assert resp.json()["description"] == "Renamed via sku"
|
||||
|
||||
resp = await client.delete("/api/augmented-parts/SKU-123", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert (
|
||||
await client.get(f"/api/augmented-parts/{ap_id}", headers=headers)
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_by_id_still_works(client, seeded, make_user, db_sessionmaker):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
ap = AugmentedPart(
|
||||
description="Delete me by id",
|
||||
colour_id=colour.id,
|
||||
manufacturer_id=manufacturer.id,
|
||||
part_type_id=part_type.id,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
ap_id = ap.id
|
||||
|
||||
await make_user("root4@example.com", is_superuser=True)
|
||||
headers = {"Authorization": f"Bearer {await _token(client, 'root4@example.com')}"}
|
||||
|
||||
resp = await client.delete(f"/api/augmented-parts/{ap_id}", headers=headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
assert (
|
||||
await client.get(f"/api/augmented-parts/{ap_id}", headers=headers)
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_and_delete_by_unknown_sku_404s(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
await make_user("root5@example.com", is_superuser=True)
|
||||
headers = {"Authorization": f"Bearer {await _token(client, 'root5@example.com')}"}
|
||||
|
||||
resp = await client.patch(
|
||||
"/api/augmented-parts/NO-SUCH-SKU",
|
||||
json={"description": "won't happen"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = await client.delete("/api/augmented-parts/NO-SUCH-SKU", headers=headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def _web_login(client, email, password="secret123"):
|
||||
resp = await client.post("/login", data={"email": email, "password": password})
|
||||
assert resp.status_code == 303
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""`on_website` and `preferred_part` booleans on AugmentedPart: default to
|
||||
False, settable via create/update (API + admin web UI), and `on_website`
|
||||
is filterable in the admin listing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.augmented_part import AugmentedPart
|
||||
from app.models.manufacturer import Manufacturer
|
||||
from app.models.part_colour import PartColour
|
||||
from app.models.part_type import PartType
|
||||
|
||||
|
||||
async def _seed_reference_data(db):
|
||||
manufacturer = Manufacturer(name="Acme", url="https://acme.test")
|
||||
colour = PartColour(name="Black")
|
||||
part_type = PartType(name="Scope")
|
||||
db.add_all([manufacturer, colour, part_type])
|
||||
await db.flush()
|
||||
return manufacturer, colour, part_type
|
||||
|
||||
|
||||
async def _token(client, email, password="secret123"):
|
||||
resp = await client.post(
|
||||
"/api/auth/login", data={"username": email, "password": password}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def _superuser_headers(client, make_user, email):
|
||||
await make_user(email, is_superuser=True)
|
||||
return {"Authorization": f"Bearer {await _token(client, email)}"}
|
||||
|
||||
|
||||
async def _web_login(client, email, password="secret123"):
|
||||
resp = await client.post("/login", data={"email": email, "password": password})
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_create_defaults_both_fields_to_false(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
manufacturer_id, colour_id, part_type_id = (
|
||||
manufacturer.id,
|
||||
colour.id,
|
||||
part_type.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
headers = await _superuser_headers(client, make_user, "owp-api1@example.com")
|
||||
resp = await client.post(
|
||||
"/api/augmented-parts",
|
||||
json={
|
||||
"description": "Widget",
|
||||
"colour_id": colour_id,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
"part_type_id": part_type_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["on_website"] is False
|
||||
assert body["preferred_part"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_create_and_update_can_set_both_fields(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
manufacturer_id, colour_id, part_type_id = (
|
||||
manufacturer.id,
|
||||
colour.id,
|
||||
part_type.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
headers = await _superuser_headers(client, make_user, "owp-api2@example.com")
|
||||
resp = await client.post(
|
||||
"/api/augmented-parts",
|
||||
json={
|
||||
"description": "Widget",
|
||||
"colour_id": colour_id,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
"part_type_id": part_type_id,
|
||||
"on_website": True,
|
||||
"preferred_part": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["on_website"] is True
|
||||
assert body["preferred_part"] is True
|
||||
ap_id = body["id"]
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/augmented-parts/{ap_id}",
|
||||
json={"on_website": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["on_website"] is False
|
||||
assert body["preferred_part"] is True # untouched by the partial update
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_admin_create_form_sets_both_checkboxes(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
manufacturer_id, colour_id, part_type_id = (
|
||||
manufacturer.id,
|
||||
colour.id,
|
||||
part_type.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await make_user("owp-web1@example.com", role_ids=[seeded["roles"]["admin"].id])
|
||||
await _web_login(client, "owp-web1@example.com")
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/augmented-parts",
|
||||
data={
|
||||
"description": "Web widget",
|
||||
"colour_id": colour_id,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
"part_type_id": part_type_id,
|
||||
"on_website": "true",
|
||||
"preferred_part": "true",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "Web widget" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_admin_update_form_toggles_both_fields(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
ap = AugmentedPart(
|
||||
description="Togglable widget",
|
||||
colour_id=colour.id,
|
||||
manufacturer_id=manufacturer.id,
|
||||
part_type_id=part_type.id,
|
||||
on_website=False,
|
||||
preferred_part=False,
|
||||
)
|
||||
db.add(ap)
|
||||
await db.commit()
|
||||
ap_id, manufacturer_id, colour_id, part_type_id = (
|
||||
ap.id,
|
||||
manufacturer.id,
|
||||
colour.id,
|
||||
part_type.id,
|
||||
)
|
||||
|
||||
await make_user("owp-web2@example.com", role_ids=[seeded["roles"]["admin"].id])
|
||||
await _web_login(client, "owp-web2@example.com")
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/augmented-parts/{ap_id}",
|
||||
data={
|
||||
"description": "Togglable widget",
|
||||
"colour_id": colour_id,
|
||||
"manufacturer_id": manufacturer_id,
|
||||
"part_type_id": part_type_id,
|
||||
"on_website": "true",
|
||||
# preferred_part omitted, as an unchecked checkbox would be
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async with db_sessionmaker() as db:
|
||||
refreshed = await db.get(AugmentedPart, ap_id)
|
||||
assert refreshed.on_website is True
|
||||
assert refreshed.preferred_part is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_admin_filter_by_on_website(
|
||||
client, seeded, make_user, db_sessionmaker
|
||||
):
|
||||
async with db_sessionmaker() as db:
|
||||
manufacturer, colour, part_type = await _seed_reference_data(db)
|
||||
on_site = AugmentedPart(
|
||||
description="On-site widget",
|
||||
colour_id=colour.id,
|
||||
manufacturer_id=manufacturer.id,
|
||||
part_type_id=part_type.id,
|
||||
on_website=True,
|
||||
)
|
||||
off_site = AugmentedPart(
|
||||
description="Off-site widget",
|
||||
colour_id=colour.id,
|
||||
manufacturer_id=manufacturer.id,
|
||||
part_type_id=part_type.id,
|
||||
on_website=False,
|
||||
)
|
||||
db.add_all([on_site, off_site])
|
||||
await db.commit()
|
||||
|
||||
await make_user("owp-web3@example.com", role_ids=[seeded["roles"]["admin"].id])
|
||||
await _web_login(client, "owp-web3@example.com")
|
||||
|
||||
resp = await client.get("/admin/augmented-parts")
|
||||
assert "On-site widget" in resp.text
|
||||
assert "Off-site widget" in resp.text
|
||||
|
||||
resp = await client.get("/admin/augmented-parts/filter?on_website=true")
|
||||
assert resp.status_code == 200
|
||||
assert "On-site widget" in resp.text
|
||||
assert "Off-site widget" not in resp.text
|
||||
|
||||
resp = await client.get("/admin/augmented-parts/filter")
|
||||
assert "On-site widget" in resp.text
|
||||
assert "Off-site widget" in resp.text
|
||||
@@ -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