## 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>
11 KiB
ADR-013: Expose deleted_at Field in Note Model
Status
Accepted
Context
The StarPunk application implements soft deletion for notes, using a deleted_at timestamp in the database to mark notes as deleted without physically removing them. However, there is a model-schema mismatch: the deleted_at column exists in the database schema but is not exposed as a field in the Note dataclass.
Current State
Database Schema (starpunk/database.py):
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
file_path TEXT UNIQUE NOT NULL,
published BOOLEAN DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP, -- Column exists
content_hash TEXT
);
Note Model (starpunk/models.py):
@dataclass(frozen=True)
class Note:
# Core fields from database
id: int
slug: str
file_path: str
published: bool
created_at: datetime
updated_at: datetime
# deleted_at: MISSING
Notes Module (starpunk/notes.py):
- Uses
deleted_atin queries (WHERE deleted_at IS NULL) - Sets
deleted_atduring soft deletion (UPDATE notes SET deleted_at = ?) - Never exposes the value through the model layer
Problem
This architecture creates several issues:
- Testability Gap: Tests cannot verify soft-deletion status because
note.deleted_atdoesn't exist - Information Hiding: The model hides database state from consumers
- Principle Violation: Data models should faithfully represent database schema
- Future Limitations: Admin UIs, debugging tools, and backup utilities cannot access deletion timestamps
Immediate Trigger
Test test_delete_without_confirmation_cancels fails with:
AttributeError: 'Note' object has no attribute 'deleted_at'
The test attempts to verify that a cancelled deletion does NOT set deleted_at:
note = get_note(id=note_id)
assert note is not None
assert note.deleted_at is None # ← Fails here
Decision
We will add deleted_at: Optional[datetime] as a field in the Note dataclass.
The field will be:
- Nullable:
Optional[datetime] = None - Extracted from database rows in
Note.from_row() - Documented in the Note docstring
- Optionally serialized in
Note.to_dict()when present
Rationale
Why Add the Field
-
Transparency Over Encapsulation
- For data models, transparency should win
- Developers expect to access any database column through the model
- Hiding fields creates semantic mismatches
-
Testability
- Tests must be able to verify soft-deletion behavior
- Current design makes deletion status verification impossible
- Exposing the field enables proper test coverage
-
Principle of Least Surprise
- If a database column exists, it should be accessible
- Other models (Session, Token, AuthState) expose all their fields
- Consistency across the codebase
-
Future Flexibility
- Admin interfaces may need to show when notes were deleted
- Data export/backup tools need complete state
- Debugging requires visibility into deletion status
-
Low Complexity Cost
- Adding one optional field is minimal complexity
- No performance impact (no additional queries)
- Backwards compatible (existing code won't break)
Why NOT Use Alternative Approaches
Alternative 1: Fix the Test Only
- Weakens test coverage (can't verify deletion status)
- Doesn't solve root problem (future code will hit same issue)
- Rejected
Alternative 2: Add Helper Property (is_deleted)
- Loses information (can't see deletion timestamp)
- Adds complexity (two fields instead of one)
- Inconsistent with other models
- Rejected
Alternative 3: Separate Model Class for Deleted Notes
- Massive complexity increase
- Violates simplicity principle
- Breaks existing code
- Rejected
Consequences
Positive Consequences
- Test Suite Passes:
test_delete_without_confirmation_cancelswill pass - Complete Model: Note model accurately reflects database schema
- Better Testability: All tests can verify soft-deletion state
- Future-Proof: Admin UIs and debugging tools have access to deletion data
- Consistency: All models expose their database fields
Negative Consequences
-
Loss of Encapsulation: Consumers now see
deleted_atand must understand soft deletion- Mitigation: Document the field clearly in docstring
- Impact: Minimal - developers working with notes should understand deletion
-
Slight Complexity Increase: Model has one more field
- Impact: One line of code, negligible complexity
Breaking Changes
None - The field is optional and nullable, so:
- Existing code that doesn't use
deleted_atcontinues to work Note.from_row()sets it toNonefor active notes- Serialization is optional
Implementation Guidance
File: starpunk/models.py
Change 1: Add Field to Dataclass
@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)
# Optional fields
content_hash: Optional[str] = None
Change 2: Update from_row() Method
Add timestamp conversion for deleted_at:
@classmethod
def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
# ... existing code ...
# 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"))
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"),
)
Change 3: Update Docstring
Add documentation for deleted_at:
@dataclass(frozen=True)
class Note:
"""
Represents a note/post
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)
# ... rest of docstring ...
"""
Change 4 (Optional): Update to_dict() Method
Add deleted_at to serialization when present:
def to_dict(
self, include_content: bool = False, include_html: bool = False
) -> dict[str, Any]:
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")
if include_content:
data["content"] = self.content
if include_html:
data["html"] = self.html
return data
Testing Strategy
Verification Steps
-
Run Failing Test:
uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -vShould pass after changes.
-
Run Full Test Suite:
uv run pytestShould pass with no regressions.
-
Manual Verification:
# Active note should have deleted_at = None note = get_note(slug="active-note") assert note.deleted_at is None # Soft-deleted note should have deleted_at set delete_note(slug="test-note", soft=True) # Note: get_note() filters out soft-deleted notes # To verify, query database directly or use admin interface
Expected Test Coverage
deleted_atisNonefor active notesdeleted_atisNonefor newly created notesdeleted_atis set after soft deletion (verify via database query)get_note()returnsNonefor soft-deleted notes (existing behavior)list_notes()excludes soft-deleted notes (existing behavior)
Acceptance Criteria
deleted_atfield added to Note dataclassfrom_row()extracts and parsesdeleted_atfrom database rowsfrom_row()handlesdeleted_atas ISO stringfrom_row()handlesdeleted_atas None (active notes)- Docstring updated to document
deleted_at - Test
test_delete_without_confirmation_cancelspasses - Full test suite passes with no regressions
- Optional:
to_dict()includesdeleted_atwhen present
Alternatives Considered
1. Update Test to Remove deleted_at Check
Approach: Modify test to not verify deletion status
Pros:
- One line change
- Maintains current encapsulation
Cons:
- Weakens test coverage
- Doesn't solve root problem
- Violates test intent
Decision: Rejected - Band-aid solution
2. Add Helper Property Instead of Raw Field
Approach: Expose is_deleted boolean property, hide timestamp
Pros:
- Encapsulates implementation
- Simple boolean interface
Cons:
- Loses deletion timestamp information
- Inconsistent with other models
- More complex than exposing field directly
Decision: Rejected - Adds complexity without clear benefit
3. Create Separate SoftDeletedNote Model
Approach: Use different classes for active vs deleted notes
Pros:
- Type safety
- Clear separation
Cons:
- Massive complexity increase
- Violates simplicity principle
- Breaks existing code
Decision: Rejected - Over-engineered for V1
References
- Test Failure Analysis:
/home/phil/Projects/starpunk/docs/reports/test-failure-analysis-deleted-at-attribute.md - Database Schema:
starpunk/database.py:11-27 - Note Model:
starpunk/models.py:44-440 - Notes Module:
starpunk/notes.py:685-849 - Failing Test:
tests/test_routes_admin.py:435-441 - ADR-004: File-Based Note Storage (discusses soft deletion design)
Related Standards
- Data Model Design: Models should faithfully represent database schema
- Testability Principle: All business logic must be testable
- Principle of Least Surprise: Developers expect database columns to be accessible
- Transparency vs Encapsulation: For data models, transparency wins
Date: 2025-11-18 Author: StarPunk Architect Agent Status: Accepted