From 4fd2bb1b58dee3dfc714e5d87d553de678f6e262 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:01:23 -0300 Subject: [PATCH] Adding on_website and preferred part indicators --- ...9c_augmented_parts_on_website_preferred.py | 41 ++++ app/api/augmented_parts.py | 2 + app/crud/augmented_part.py | 19 +- app/models/augmented_part.py | 2 + app/schemas/augmented_part.py | 4 + app/templates/admin/_augmented_part_row.html | 2 + .../admin/_augmented_part_row_edit.html | 4 +- .../admin/_augmented_parts_table.html | 1 + .../admin/_augmented_parts_table_card.html | 4 +- .../admin/augmented_part_detail.html | 2 + app/templates/admin/augmented_parts.html | 2 + app/web/admin.py | 28 ++- ...est_augmented_part_on_website_preferred.py | 226 ++++++++++++++++++ 13 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/4d5e6f7a8b9c_augmented_parts_on_website_preferred.py create mode 100644 tests/test_augmented_part_on_website_preferred.py diff --git a/alembic/versions/4d5e6f7a8b9c_augmented_parts_on_website_preferred.py b/alembic/versions/4d5e6f7a8b9c_augmented_parts_on_website_preferred.py new file mode 100644 index 0000000..6eb9b4c --- /dev/null +++ b/alembic/versions/4d5e6f7a8b9c_augmented_parts_on_website_preferred.py @@ -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") diff --git a/app/api/augmented_parts.py b/app/api/augmented_parts.py index 28ba812..11bd6cc 100644 --- a/app/api/augmented_parts.py +++ b/app/api/augmented_parts.py @@ -59,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, diff --git a/app/crud/augmented_part.py b/app/crud/augmented_part.py index b3a4f89..164634f 100644 --- a/app/crud/augmented_part.py +++ b/app/crud/augmented_part.py @@ -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() diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py index d506dea..0a1f02e 100644 --- a/app/models/augmented_part.py +++ b/app/models/augmented_part.py @@ -47,6 +47,8 @@ class AugmentedPart(Base): # 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( diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py index 1a393ae..a100750 100644 --- a/app/schemas/augmented_part.py +++ b/app/schemas/augmented_part.py @@ -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,6 +56,8 @@ 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): diff --git a/app/templates/admin/_augmented_part_row.html b/app/templates/admin/_augmented_part_row.html index da689f2..4b4c07d 100644 --- a/app/templates/admin/_augmented_part_row.html +++ b/app/templates/admin/_augmented_part_row.html @@ -8,6 +8,8 @@ {{ ap.part_type.name }} {{ ap.sub_part_type.name if ap.sub_part_type else '—' }} {% if ap.archived %}Archived{% else %}—{% endif %} + {% if ap.on_website %}Yes{% else %}—{% endif %} + {% if ap.preferred_part %}Yes{% else %}—{% endif %} diff --git a/app/web/admin.py b/app/web/admin.py index 6293aa5..2102ed9 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1674,6 +1674,7 @@ async def _default_augmented_parts_table_ctx(db: AsyncSession) -> dict: "sort": "description", "dir": "asc", "include_archived": False, + "on_website": False, } @@ -1683,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, @@ -1707,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, @@ -1716,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) @@ -1730,6 +1739,8 @@ 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")), ): @@ -1745,6 +1756,8 @@ async def create_augmented_part( 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, @@ -1874,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, @@ -1895,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, @@ -1904,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", @@ -1922,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"], @@ -1945,6 +1962,7 @@ async def filter_augmented_parts( "sort": sort, "dir": dir, "include_archived": include_archived, + "on_website": on_website, }, ) @@ -2188,6 +2206,8 @@ 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")), ): @@ -2207,6 +2227,8 @@ async def update_augmented_part( "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( diff --git a/tests/test_augmented_part_on_website_preferred.py b/tests/test_augmented_part_on_website_preferred.py new file mode 100644 index 0000000..0c4f018 --- /dev/null +++ b/tests/test_augmented_part_on_website_preferred.py @@ -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