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 @@ +
{{ note.body }}
+No notes recorded yet.
+ {% endfor %} + {% if can_write_notes %} + + {% endif %} +