# Implementation Guide: Expose deleted_at in Note Model **Date**: 2025-11-18 **Issue**: Test `test_delete_without_confirmation_cancels` fails with `AttributeError: 'Note' object has no attribute 'deleted_at'` **Decision**: ADR-013 - Expose deleted_at Field in Note Model **Complexity**: LOW (3-4 line changes) **Time Estimate**: 5 minutes implementation + 2 minutes testing --- ## Quick Summary The `deleted_at` column exists in the database but is not exposed in the `Note` dataclass. This creates a model-schema mismatch that prevents tests from verifying soft-deletion status. **Fix**: Add `deleted_at: Optional[datetime] = None` to the Note model. --- ## Implementation Steps ### Step 1: Add Field to Note Dataclass **File**: `starpunk/models.py` **Location**: Around line 109 **Change**: ```python @dataclass(frozen=True) class Note: """Represents a note/post""" # Core fields from database id: int slug: str file_path: str published: bool created_at: datetime updated_at: datetime deleted_at: Optional[datetime] = None # ← ADD THIS LINE # Internal fields (not from database) _data_dir: Path = field(repr=False, compare=False) ``` ### Step 2: Extract deleted_at in from_row() **File**: `starpunk/models.py` **Location**: Around line 145-162 in `from_row()` method **Add timestamp conversion** (after `updated_at` conversion): ```python # Convert timestamps if they are strings created_at = data["created_at"] if isinstance(created_at, str): created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) updated_at = data["updated_at"] if isinstance(updated_at, str): updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) # ← ADD THIS BLOCK deleted_at = data.get("deleted_at") if deleted_at and isinstance(deleted_at, str): deleted_at = datetime.fromisoformat(deleted_at.replace("Z", "+00:00")) ``` **Update return statement** (add `deleted_at` parameter): ```python return cls( id=data["id"], slug=data["slug"], file_path=data["file_path"], published=bool(data["published"]), created_at=created_at, updated_at=updated_at, deleted_at=deleted_at, # ← ADD THIS LINE _data_dir=data_dir, content_hash=data.get("content_hash"), ) ``` ### Step 3: Update Docstring **File**: `starpunk/models.py` **Location**: Around line 60 in Note docstring **Add to Attributes section**: ```python Attributes: id: Database ID (primary key) slug: URL-safe slug (unique) file_path: Path to markdown file (relative to data directory) published: Whether note is published (visible publicly) created_at: Creation timestamp (UTC) updated_at: Last update timestamp (UTC) deleted_at: Soft deletion timestamp (UTC, None if not deleted) # ← ADD THIS LINE content_hash: SHA-256 hash of content (for integrity checking) ``` ### Step 4 (Optional): Include in to_dict() Serialization **File**: `starpunk/models.py` **Location**: Around line 389-398 in `to_dict()` method **Add after excerpt** (optional, for API consistency): ```python data = { "id": self.id, "slug": self.slug, "title": self.title, "published": self.published, "created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%SZ"), "updated_at": self.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ"), "permalink": self.permalink, "excerpt": self.excerpt, } # ← ADD THIS BLOCK (optional) if self.deleted_at is not None: data["deleted_at"] = self.deleted_at.strftime("%Y-%m-%dT%H:%M:%SZ") ``` --- ## Testing ### Run Failing Test ```bash uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -v ``` **Expected**: Test should PASS ### Run Full Test Suite ```bash uv run pytest ``` **Expected**: All tests should pass with no regressions ### Manual Verification (Optional) ```python from starpunk.notes import get_note, create_note, delete_note # Create a test note note = create_note("Test content", published=False) # Verify deleted_at is None for active notes assert note.deleted_at is None # Soft delete the note delete_note(slug=note.slug, soft=True) # Note: get_note() filters out soft-deleted notes by default # To verify deletion timestamp, query database directly: from starpunk.database import get_db from flask import current_app db = get_db(current_app) row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone() assert row["deleted_at"] is not None # Should have timestamp ``` --- ## Complete Diff Here's the complete change summary: **starpunk/models.py**: ```diff @@ -44,6 +44,7 @@ class Note: slug: str file_path: str published: bool created_at: datetime updated_at: datetime + deleted_at: Optional[datetime] = None @@ -60,6 +61,7 @@ class Note: published: Whether note is published (visible publicly) created_at: Creation timestamp (UTC) updated_at: Last update timestamp (UTC) + deleted_at: Soft deletion timestamp (UTC, None if not deleted) content_hash: SHA-256 hash of content (for integrity checking) @@ -150,6 +152,10 @@ def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note": if isinstance(updated_at, str): updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + deleted_at = data.get("deleted_at") + if deleted_at and isinstance(deleted_at, str): + deleted_at = datetime.fromisoformat(deleted_at.replace("Z", "+00:00")) + return cls( id=data["id"], slug=data["slug"], @@ -157,6 +163,7 @@ def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note": published=bool(data["published"]), created_at=created_at, updated_at=updated_at, + deleted_at=deleted_at, _data_dir=data_dir, content_hash=data.get("content_hash"), ) ``` --- ## Verification Checklist After implementation, verify: - [ ] `deleted_at` field exists in Note dataclass - [ ] Field has type `Optional[datetime]` with default `None` - [ ] `from_row()` extracts `deleted_at` from database rows - [ ] `from_row()` handles ISO string format timestamps - [ ] `from_row()` handles None values (active notes) - [ ] Docstring documents the `deleted_at` field - [ ] Test `test_delete_without_confirmation_cancels` passes - [ ] Full test suite passes - [ ] No import errors (datetime and Optional already imported) --- ## Why This Fix Is Correct 1. **Root Cause**: Model-schema mismatch - database has `deleted_at` but model doesn't expose it 2. **Principle**: Data models should faithfully represent database schema 3. **Testability**: Tests need to verify soft-deletion behavior 4. **Simplicity**: One field addition, minimal complexity 5. **Backwards Compatible**: Optional field won't break existing code --- ## References - **ADR**: `/home/phil/Projects/starpunk/docs/decisions/ADR-013-expose-deleted-at-in-note-model.md` - **Analysis**: `/home/phil/Projects/starpunk/docs/reports/test-failure-analysis-deleted-at-attribute.md` - **File to Edit**: `/home/phil/Projects/starpunk/starpunk/models.py` - **Test File**: `/home/phil/Projects/starpunk/tests/test_routes_admin.py` --- ## Questions? **Q: Why not hide this field?** A: Transparency wins for data models. Tests and admin UIs need access to deletion status. **Q: Will this break existing code?** A: No. The field is optional (nullable), so existing code continues to work. **Q: Why not use `is_deleted` property instead?** A: That would lose the deletion timestamp information, which is valuable for debugging and admin UIs. **Q: Do I need a database migration?** A: No. The `deleted_at` column already exists in the database schema. --- **Ready to implement? The changes are minimal and low-risk.**