Files
StarPunk/docs/reports/implementation-guide-expose-deleted-at.md
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

7.8 KiB

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:

@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):

    # 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):

    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:

    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):

    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

uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -v

Expected: Test should PASS

Run Full Test Suite

uv run pytest

Expected: All tests should pass with no regressions

Manual Verification (Optional)

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:

@@ -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.