Adding AugmentedItem notes
This commit is contained in:
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")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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] = []
|
||||
@@ -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" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
{% include "admin/_augmented_part_notes.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
+44
-1
@@ -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 ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user