From 34679d53e81b536795a6157c215f5a93d402bbc3 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:12:41 -0300 Subject: [PATCH] Adding AugmentedItem notes --- ...c00b0c04_add_augmented_part_notes_table.py | 43 ++++++++++++++++ app/api/augmented_parts.py | 22 ++++++++ app/crud/augmented_part_note.py | 19 +++++++ app/models/__init__.py | 3 +- app/models/augmented_part.py | 31 +++++++++++- app/schemas/augmented_part.py | 27 +++++++++- .../admin/_augmented_part_notes.html | 22 ++++++++ .../admin/augmented_part_detail.html | 4 ++ app/web/admin.py | 45 ++++++++++++++++- tests/test_augmented_part_detail.py | 50 ++++++++++++++++--- 10 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/8012c00b0c04_add_augmented_part_notes_table.py create mode 100644 app/crud/augmented_part_note.py create mode 100644 app/templates/admin/_augmented_part_notes.html diff --git a/alembic/versions/8012c00b0c04_add_augmented_part_notes_table.py b/alembic/versions/8012c00b0c04_add_augmented_part_notes_table.py new file mode 100644 index 0000000..5e8764a --- /dev/null +++ b/alembic/versions/8012c00b0c04_add_augmented_part_notes_table.py @@ -0,0 +1,43 @@ +"""add_augmented_part_notes_table + +Revision ID: 8012c00b0c04 +Revises: d1e2f3a4b5c6 +Create Date: 2026-07-31 09:07:06.137175 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "8012c00b0c04" +down_revision: Union[str, None] = "d1e2f3a4b5c6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "augmented_part_notes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("augmented_part_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["augmented_part_id"], ["augmented_parts.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("augmented_part_notes") diff --git a/app/api/augmented_parts.py b/app/api/augmented_parts.py index ac8d130..c624529 100644 --- a/app/api/augmented_parts.py +++ b/app/api/augmented_parts.py @@ -4,10 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.crud import audit as audit_crud from app.crud import augmented_part as augmented_part_crud +from app.crud import augmented_part_note as augmented_part_note_crud from app.deps import require_permission from app.models.user import User from app.schemas.augmented_part import ( AugmentedPartCreate, + AugmentedPartNoteCreate, + AugmentedPartNoteRead, AugmentedPartRead, AugmentedPartSyncResult, AugmentedPartUpdate, @@ -132,3 +135,22 @@ async def delete_augmented_part( "AugmentedPart", augmented_part_id, label ), ) + + +@router.post( + "/{augmented_part_id}/notes", + response_model=AugmentedPartNoteRead, + status_code=status.HTTP_201_CREATED, +) +async def create_augmented_part_note( + augmented_part_id: int, + payload: AugmentedPartNoteCreate, + db: AsyncSession = Depends(get_db), + actor: User = Depends(require_permission("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") + return await augmented_part_note_crud.create( + db, augmented_part=ap, author=actor, body=payload.body + ) diff --git a/app/crud/augmented_part_note.py b/app/crud/augmented_part_note.py new file mode 100644 index 0000000..8145d25 --- /dev/null +++ b/app/crud/augmented_part_note.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.augmented_part import AugmentedPart, AugmentedPartNote +from app.models.user import User + + +async def create( + db: AsyncSession, *, augmented_part: AugmentedPart, author: User, body: str +) -> AugmentedPartNote: + note = AugmentedPartNote( + augmented_part_id=augmented_part.id, + user_id=author.id, + username=author.full_name or author.email, + body=body, + ) + db.add(note) + await db.commit() + await db.refresh(note) + return note diff --git a/app/models/__init__.py b/app/models/__init__.py index 67d0248..4351951 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,7 +8,7 @@ from app.models.associations import ( from app.models.attachment_point import AttachmentPoint from app.models.attachment_point_name import AttachmentPointName from app.models.audit_log import AuditLog -from app.models.augmented_part import AugmentedPart +from app.models.augmented_part import AugmentedPart, AugmentedPartNote from app.models.clamp_connection import ClampConnection from app.models.fit import Fit, FitLabel, FitNote from app.models.imported_part import ImportedPart @@ -32,6 +32,7 @@ __all__ = [ "AttachmentPointName", "AuditLog", "AugmentedPart", + "AugmentedPartNote", "ClampConnection", "Fit", "FitLabel", diff --git a/app/models/augmented_part.py b/app/models/augmented_part.py index 757fafd..6425869 100644 --- a/app/models/augmented_part.py +++ b/app/models/augmented_part.py @@ -1,4 +1,6 @@ -from sqlalchemy import ForeignKey, String +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -45,3 +47,30 @@ class AugmentedPart(Base): scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") part_type: Mapped[PartType] = relationship(lazy="selectin") sub_part_type: Mapped[SubPartType | None] = relationship(lazy="selectin") + notes: Mapped[list["AugmentedPartNote"]] = relationship( + back_populates="augmented_part", + lazy="selectin", + order_by="AugmentedPartNote.created_at", + cascade="all, delete-orphan", + ) + + +class AugmentedPartNote(Base): + __tablename__ = "augmented_part_notes" + + id: Mapped[int] = mapped_column(primary_key=True) + augmented_part_id: Mapped[int] = mapped_column( + ForeignKey("augmented_parts.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + # Denormalized snapshot of who wrote the note (mirrors FitNote.username), so + # the note stays attributable even if the user is later deleted. + username: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + augmented_part: Mapped[AugmentedPart] = relationship(back_populates="notes") diff --git a/app/schemas/augmented_part.py b/app/schemas/augmented_part.py index 6894bb7..2f389ed 100644 --- a/app/schemas/augmented_part.py +++ b/app/schemas/augmented_part.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, ConfigDict +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator from app.schemas.manufacturer import ManufacturerRead from app.schemas.part_colour import PartColourRead @@ -7,6 +9,28 @@ from app.schemas.scope_model import ScopeModelRead from app.schemas.sub_part_type import SubPartTypeRead +class AugmentedPartNoteCreate(BaseModel): + body: str + + @field_validator("body") + @classmethod + def validate_body(cls, v: str) -> str: + if not v.strip(): + raise ValueError("body must not be blank") + return v.strip() + + +class AugmentedPartNoteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + augmented_part_id: int + user_id: int | None + username: str + body: str + created_at: datetime + + class AugmentedPartBase(BaseModel): sku: str | None = None description: str @@ -46,3 +70,4 @@ class AugmentedPartRead(AugmentedPartBase): scope_model: ScopeModelRead | None part_type: PartTypeRead sub_part_type: SubPartTypeRead | None + notes: list[AugmentedPartNoteRead] = [] diff --git a/app/templates/admin/_augmented_part_notes.html b/app/templates/admin/_augmented_part_notes.html new file mode 100644 index 0000000..8ef8ecd --- /dev/null +++ b/app/templates/admin/_augmented_part_notes.html @@ -0,0 +1,22 @@ +
+

Notes

+ +
+ {% for note in notes %} +
+ {{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }} +

{{ note.body }}

+
+ {% else %} +

No notes recorded yet.

+ {% endfor %} + {% if can_write_notes %} +
+ + +
+ {% endif %} +
+
diff --git a/app/templates/admin/augmented_part_detail.html b/app/templates/admin/augmented_part_detail.html index 2d9caec..14834d3 100644 --- a/app/templates/admin/augmented_part_detail.html +++ b/app/templates/admin/augmented_part_detail.html @@ -61,5 +61,9 @@ {% include "admin/_augmented_part_fits.html" %} {% endif %} + +
+ {% include "admin/_augmented_part_notes.html" %} +
{% endblock %} diff --git a/app/web/admin.py b/app/web/admin.py index 0785630..5f9ee0d 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.crud import audit as audit_crud from app.crud import augmented_part as augmented_part_crud +from app.crud import augmented_part_note as augmented_part_note_crud from app.crud import imported_part as imported_part_crud from app.crud import imported_part_config as imported_part_config_crud from app.crud import iso_thread as iso_thread_crud @@ -1803,6 +1804,9 @@ async def augmented_part_detail( ), "can_read_fits": can_read_fits, "can_read_attachment_points": can_read_attachment_points, + "augmented_part_id": augmented_part_id, + "notes": ap.notes, + "can_write_notes": _can_write_augmented_part_notes(user), } if can_read_attachment_points: ctx.update( @@ -2333,7 +2337,9 @@ async def create_augmented_part_fit( resource_type="Fit", resource_id=fit.id, description=audit_crud.describe_create( - "Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id} ({label.value})" + "Fit", + fit.id, + f"attachment points {ap_a.id}↔{ap_b.id} ({label.value})", ), ) if note.strip(): @@ -2464,6 +2470,43 @@ async def recompute_all_fits( ) +# --- Augmented Part Notes ------------------------------------------------- + + +def _can_write_augmented_part_notes(user: User) -> bool: + return user.is_superuser or "augmented_parts:write" in user.permissions + + +@router.post( + "/augmented-parts/{augmented_part_id}/notes", + response_class=HTMLResponse, +) +async def create_augmented_part_note( + request: Request, + augmented_part_id: int, + body: str = Form(...), + 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") + if body.strip(): + await augmented_part_note_crud.create( + db, augmented_part=ap, author=actor, body=body.strip() + ) + await db.refresh(ap, attribute_names=["notes"]) + return templates.TemplateResponse( + request, + "admin/_augmented_part_notes.html", + { + "augmented_part_id": augmented_part_id, + "notes": ap.notes, + "can_write_notes": _can_write_augmented_part_notes(actor), + }, + ) + + # --- Part Colours ------------------------------------------------------------ diff --git a/tests/test_augmented_part_detail.py b/tests/test_augmented_part_detail.py index c4c227e..9c93f9e 100644 --- a/tests/test_augmented_part_detail.py +++ b/tests/test_augmented_part_detail.py @@ -3,7 +3,7 @@ import pytest from app.crud import role as role_crud from app.models.attachment_point import AttachmentPoint from app.models.attachment_point_name import AttachmentPointName -from app.models.augmented_part import AugmentedPart +from app.models.augmented_part import AugmentedPart, AugmentedPartNote from app.models.fit import Fit, FitLabel, FitNote from app.models.iso_thread import IsoThread from app.models.part_type import PartType @@ -41,12 +41,8 @@ async def _seed_part_with_fit(db): objective_thread_pitch="M52x0.75", ) ) - ap_s = AttachmentPoint( - augmented_part_id=scope.id, attachment_point_name_id=apn.id - ) - ap_c = AttachmentPoint( - augmented_part_id=cover.id, attachment_point_name_id=apn.id - ) + ap_s = AttachmentPoint(augmented_part_id=scope.id, attachment_point_name_id=apn.id) + ap_c = AttachmentPoint(augmented_part_id=cover.id, attachment_point_name_id=apn.id) db.add_all([ap_s, ap_c]) await db.flush() @@ -92,6 +88,7 @@ async def test_detail_shows_attributes_and_fits_readonly( assert "Add fit" not in body assert "Mark Exact" not in body assert "Recompute possible fits" not in body + assert "Add note" not in body # ...and attachment-point add/edit/delete controls are hidden too assert 'name="attachment_point_name_id"' not in body assert "Delete this attachment point?" not in body @@ -310,6 +307,41 @@ async def test_create_fit_with_note_creates_fit_note( assert notes[0].body == "Confirmed fit on the bench." +@pytest.mark.asyncio +async def test_create_augmented_part_note(client, seeded, make_user, db_sessionmaker): + async with db_sessionmaker() as db: + ids = await _seed_part_with_fit(db) + perms = seeded["perms"] + editor = await role_crud.create( + db, + name="ap-note-editor", + permission_ids=[ + perms["augmented_parts:read"].id, + perms["augmented_parts:write"].id, + ], + ) + editor_id = editor.id + await make_user("noteeditor@example.com", role_ids=[editor_id]) + await _web_login(client, "noteeditor@example.com") + + resp = await client.get(f"/admin/augmented-parts/{ids['scope_id']}") + assert resp.status_code == 200 + assert "Add note" in resp.text + + resp = await client.post( + f"/admin/augmented-parts/{ids['scope_id']}/notes", + data={"body": "Verified thread depth on the bench."}, + ) + assert resp.status_code == 200 + assert "Verified thread depth on the bench." in resp.text + + async with db_sessionmaker() as db: + notes = (await db.execute(AugmentedPartNote.__table__.select())).all() + assert len(notes) == 1 + assert notes[0].augmented_part_id == ids["scope_id"] + assert notes[0].body == "Verified thread depth on the bench." + + @pytest.mark.asyncio async def test_detail_404_for_missing_part(client, seeded, make_user): await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id]) @@ -319,7 +351,9 @@ async def test_detail_404_for_missing_part(client, seeded, make_user): @pytest.mark.asyncio -async def test_detail_requires_read_permission(client, seeded, make_user, db_sessionmaker): +async def test_detail_requires_read_permission( + client, seeded, make_user, db_sessionmaker +): async with db_sessionmaker() as db: ids = await _seed_part_with_fit(db) await make_user("plain@example.com") # no roles, not superuser