Adding AugmentedItem notes

This commit is contained in:
Ian Roddis committed 2026-07-31 09:12:41 -03:00
1 parent 30a13644ce
commit 34679d53e8
10 files changed
+254 -12

No files matched your search

@@ -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")
+22
View File
@@ -4,10 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_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.deps import require_permission
from app.models.user import User from app.models.user import User
from app.schemas.augmented_part import ( from app.schemas.augmented_part import (
AugmentedPartCreate, AugmentedPartCreate,
AugmentedPartNoteCreate,
AugmentedPartNoteRead,
AugmentedPartRead, AugmentedPartRead,
AugmentedPartSyncResult, AugmentedPartSyncResult,
AugmentedPartUpdate, AugmentedPartUpdate,
@@ -132,3 +135,22 @@ async def delete_augmented_part(
"AugmentedPart", augmented_part_id, label "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
)
+19
View File
@@ -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
+2 -1
View File
@@ -8,7 +8,7 @@ from app.models.associations import (
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName from app.models.attachment_point_name import AttachmentPointName
from app.models.audit_log import AuditLog 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.clamp_connection import ClampConnection
from app.models.fit import Fit, FitLabel, FitNote from app.models.fit import Fit, FitLabel, FitNote
from app.models.imported_part import ImportedPart from app.models.imported_part import ImportedPart
@@ -32,6 +32,7 @@ __all__ = [
"AttachmentPointName", "AttachmentPointName",
"AuditLog", "AuditLog",
"AugmentedPart", "AugmentedPart",
"AugmentedPartNote",
"ClampConnection", "ClampConnection",
"Fit", "Fit",
"FitLabel", "FitLabel",
+30 -1
View File
@@ -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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -45,3 +47,30 @@ class AugmentedPart(Base):
scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin") scope_model: Mapped[ScopeModel | None] = relationship(lazy="selectin")
part_type: Mapped[PartType] = relationship(lazy="selectin") part_type: Mapped[PartType] = relationship(lazy="selectin")
sub_part_type: Mapped[SubPartType | None] = 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")
+26 -1
View File
@@ -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.manufacturer import ManufacturerRead
from app.schemas.part_colour import PartColourRead 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 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): class AugmentedPartBase(BaseModel):
sku: str | None = None sku: str | None = None
description: str description: str
@@ -46,3 +70,4 @@ class AugmentedPartRead(AugmentedPartBase):
scope_model: ScopeModelRead | None scope_model: ScopeModelRead | None
part_type: PartTypeRead part_type: PartTypeRead
sub_part_type: SubPartTypeRead | None sub_part_type: SubPartTypeRead | None
notes: list[AugmentedPartNoteRead] = []
@@ -0,0 +1,22 @@
<div id="ap-{{ augmented_part_id }}-notes" class="ap-inline-section">
<h3>Notes</h3>
<div class="fit-notes">
{% for note in notes %}
<div class="fit-note">
<span class="fit-note-meta">{{ note.username }} · {{ note.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
<p class="fit-note-body">{{ note.body }}</p>
</div>
{% else %}
<p class="muted" style="font-size:0.85rem;margin:0 0 0.5rem">No notes recorded yet.</p>
{% endfor %}
{% if can_write_notes %}
<form hx-post="/admin/augmented-parts/{{ augmented_part_id }}/notes"
hx-target="#ap-{{ augmented_part_id }}-notes" hx-swap="outerHTML"
class="inline-edit fit-note-form">
<input type="text" name="body" placeholder="Add a note…" required>
<button type="submit" class="btn btn-ghost">Add note</button>
</form>
{% endif %}
</div>
</div>
@@ -61,5 +61,9 @@
{% include "admin/_augmented_part_fits.html" %} {% include "admin/_augmented_part_fits.html" %}
</div> </div>
{% endif %} {% endif %}
<div class="card">
{% include "admin/_augmented_part_notes.html" %}
</div>
</main> </main>
{% endblock %} {% endblock %}
+44 -1
View File
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import augmented_part as augmented_part_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 as imported_part_crud
from app.crud import imported_part_config as imported_part_config_crud from app.crud import imported_part_config as imported_part_config_crud
from app.crud import iso_thread as iso_thread_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_fits": can_read_fits,
"can_read_attachment_points": can_read_attachment_points, "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: if can_read_attachment_points:
ctx.update( ctx.update(
@@ -2333,7 +2337,9 @@ async def create_augmented_part_fit(
resource_type="Fit", resource_type="Fit",
resource_id=fit.id, resource_id=fit.id,
description=audit_crud.describe_create( 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(): 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 ------------------------------------------------------------ # --- Part Colours ------------------------------------------------------------
+42 -8
View File
@@ -3,7 +3,7 @@ import pytest
from app.crud import role as role_crud from app.crud import role as role_crud
from app.models.attachment_point import AttachmentPoint from app.models.attachment_point import AttachmentPoint
from app.models.attachment_point_name import AttachmentPointName 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.fit import Fit, FitLabel, FitNote
from app.models.iso_thread import IsoThread from app.models.iso_thread import IsoThread
from app.models.part_type import PartType from app.models.part_type import PartType
@@ -41,12 +41,8 @@ async def _seed_part_with_fit(db):
objective_thread_pitch="M52x0.75", objective_thread_pitch="M52x0.75",
) )
) )
ap_s = AttachmentPoint( ap_s = AttachmentPoint(augmented_part_id=scope.id, attachment_point_name_id=apn.id)
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_c = AttachmentPoint(
augmented_part_id=cover.id, attachment_point_name_id=apn.id
)
db.add_all([ap_s, ap_c]) db.add_all([ap_s, ap_c])
await db.flush() await db.flush()
@@ -92,6 +88,7 @@ async def test_detail_shows_attributes_and_fits_readonly(
assert "Add fit" not in body assert "Add fit" not in body
assert "Mark Exact" not in body assert "Mark Exact" not in body
assert "Recompute possible fits" 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 # ...and attachment-point add/edit/delete controls are hidden too
assert 'name="attachment_point_name_id"' not in body assert 'name="attachment_point_name_id"' not in body
assert "Delete this attachment point?" 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." 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 @pytest.mark.asyncio
async def test_detail_404_for_missing_part(client, seeded, make_user): async def test_detail_404_for_missing_part(client, seeded, make_user):
await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id]) 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 @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: async with db_sessionmaker() as db:
ids = await _seed_part_with_fit(db) ids = await _seed_part_with_fit(db)
await make_user("plain@example.com") # no roles, not superuser await make_user("plain@example.com") # no roles, not superuser