## 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>
15 KiB
Test Failure Analysis: Missing deleted_at Attribute on Note Model
Date: 2025-11-18
Status: Issue Identified - Architectural Guidance Provided
Test: test_delete_without_confirmation_cancels (tests/test_routes_admin.py:441)
Error: AttributeError: 'Note' object has no attribute 'deleted_at'
Executive Summary
A test is failing because it expects the Note model to expose a deleted_at attribute, but this field is not included in the Note dataclass definition despite being present in the database schema. This is a model-schema mismatch issue.
Root Cause: The deleted_at column exists in the database (starpunk/database.py:20) but is not mapped to the Note dataclass (starpunk/models.py:44-121).
Impact:
- Test suite failure prevents CI/CD pipeline success
- Soft deletion feature is partially implemented but not fully exposed through the model layer
- Code that attempts to check deletion status will fail at runtime
Recommended Fix: Add deleted_at field to the Note dataclass definition
Analysis
1. Database Schema Review
File: starpunk/database.py:11-27
The database schema includes a deleted_at column:
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, -- ← THIS FIELD EXISTS
content_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
Key Findings:
deleted_atis defined as a nullable TIMESTAMP column- An index exists on
deleted_atfor query performance - The schema supports soft deletion architecture
2. Note Model Review
File: starpunk/models.py:44-121
The Note dataclass does not include deleted_at:
@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
# Internal fields (not from database)
_data_dir: Path = field(repr=False, compare=False)
# Optional fields
content_hash: Optional[str] = None
# ← MISSING: deleted_at field
Key Findings:
- The model lists 6 "core fields from database" but only includes 6 of the 7 columns
deleted_atis completely absent from the dataclass definition- The
from_row()class method (line 123-162) does not extractdeleted_atfrom database rows
3. Notes Module Review
File: starpunk/notes.py
The notes module uses deleted_at in queries but never exposes it:
# Line 358-364: get_note() filters by deleted_at
row = db.execute(
"SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL", (slug,)
).fetchone()
# Line 494: list_notes() filters by deleted_at
query = "SELECT * FROM notes WHERE deleted_at IS NULL"
# Line 800-804: delete_note() sets deleted_at for soft deletes
db.execute(
"UPDATE notes SET deleted_at = ? WHERE id = ?",
(deleted_at, existing_note.id),
)
Key Findings:
- The application logic knows about
deleted_at - Queries correctly filter out soft-deleted notes (
deleted_at IS NULL) - Soft deletion is implemented by setting
deleted_atto current timestamp - However, the model layer never reads this value back from the database
- This creates a semantic gap: the database has the data, but the model can't access it
4. Failing Test Review
File: tests/test_routes_admin.py:441
The test expects to verify deletion status:
def test_delete_without_confirmation_cancels(self, authenticated_client, sample_notes):
"""Test that delete without confirmation cancels operation"""
# ... test logic ...
# Verify note was NOT deleted (still exists)
with authenticated_client.application.app_context():
from starpunk.notes import get_note
note = get_note(id=note_id)
assert note is not None # Note should still exist
assert note.deleted_at is None # NOT soft-deleted ← FAILS HERE
Key Findings:
- Test wants to explicitly verify that a note is not soft-deleted
- This is a reasonable test - it validates business logic
- The test assumes
deleted_atis accessible on the Note model - Without the field, the test cannot verify soft-deletion status
Architectural Assessment
Why This Is a Problem
-
Model-Schema Mismatch: The fundamental rule of data models is that they should accurately represent the database schema. Currently,
Noteis incomplete. -
Information Hiding: The application knows about soft deletion (it uses it), but the model layer hides this information from consumers. This violates the principle of least surprise.
-
Testing Limitation: Tests cannot verify soft-deletion behavior without accessing the field. This creates a testing blind spot.
-
Future Maintenance: Any code that needs to check deletion status (admin UI, API responses, debugging tools) will face the same issue.
Why deleted_at Was Omitted
Looking at the git history and design patterns, I can infer the reasoning:
-
Query-Level Filtering: The developer chose to filter soft-deleted notes at the query level (
WHERE deleted_at IS NULL), makingdeleted_atinvisible to consumers. -
Encapsulation: This follows a pattern of "consumers shouldn't need to know about deletion mechanics" - they just get active notes.
-
Simplicity: By excluding
deleted_at, the model is simpler and consumers don't need to remember to filter it.
This is a defensible design choice for application code, but it creates problems for:
- Testing
- Admin interfaces (where you might want to show soft-deleted items)
- Debugging
- Data export/backup tools
Design Principles at Stake
-
Transparency vs Encapsulation:
- Encapsulation says: "Hide implementation details (soft deletion) from consumers"
- Transparency says: "Expose database state accurately"
- Verdict: For data models, transparency should win
-
Data Integrity:
- The model should be a faithful representation of the database
- Hiding fields creates a semantic mismatch
- Verdict: Add the field
-
Testability:
- Tests need to verify deletion behavior
- Current design makes this impossible
- Verdict: Add the field
Architectural Decision
Decision: Add deleted_at: Optional[datetime] to the Note dataclass
Rationale:
-
Principle of Least Surprise: If a database column exists, developers expect to access it through the model
-
Testability: Tests must be able to verify soft-deletion state
-
Transparency: Data models should accurately reflect database schema
-
Future Flexibility: Admin UIs, backup tools, and debugging features will need this field
-
Low Complexity Cost: Adding one optional field is minimal complexity
-
Backwards Compatibility: The field is optional (nullable), so existing code won't break
Trade-offs Accepted:
-
Loss of Encapsulation: Consumers now see "deleted_at" and must understand soft deletion
- Mitigation: Document the field clearly; provide helper properties if needed
-
Slight Complexity Increase: Model has one more field
- Impact: Minimal - one line of code
Implementation Plan
Changes Required
File: starpunk/models.py
- Add
deleted_atfield to Note dataclass (line ~109):
@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
# Internal fields (not from database)
_data_dir: Path = field(repr=False, compare=False)
# Optional fields
content_hash: Optional[str] = None
- Update
from_row()class method to extractdeleted_at(line ~145-162):
@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
_data_dir=data_dir,
content_hash=data.get("content_hash"),
)
- (Optional) Update
to_dict()method to includedeleted_atwhen serializing (line ~354-406):
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, for API consistency)
if self.deleted_at is not None:
data["deleted_at"] = self.deleted_at.strftime("%Y-%m-%dT%H:%M:%SZ")
# ... rest of method ...
- Update docstring to document the field (line ~44-100):
@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
content_hash: SHA-256 hash of content (for integrity checking)
# ... rest of docstring ...
"""
Testing Strategy
Unit Tests:
- Verify
Note.from_row()correctly parsesdeleted_atfrom database rows - Verify
deleted_atdefaults toNonefor active notes - Verify
deleted_atis set to timestamp for soft-deleted notes - Verify
to_dict()includesdeleted_atwhen present
Integration Tests:
- The failing test should pass:
test_delete_without_confirmation_cancels - Verify soft-deleted notes have
deleted_atset afterdelete_note(soft=True) - Verify
get_note()returnsNonefor soft-deleted notes (existing behavior) - Verify hard-deleted notes are removed entirely (existing behavior)
Regression Tests:
- Run full test suite to ensure no existing tests break
- Verify
list_notes()still excludes soft-deleted notes - Verify
get_note()still excludes soft-deleted notes
Acceptance Criteria
deleted_atfield added to Note dataclassfrom_row()extractsdeleted_atfrom database rowsfrom_row()handlesdeleted_atas string (ISO format)from_row()handlesdeleted_atas None (active notes)- Docstring updated to document
deleted_at - Test
test_delete_without_confirmation_cancelspasses - Full test suite passes
- No regression in existing functionality
Alternative Approaches Considered
Alternative 1: Update Test to Remove deleted_at Check
Approach: Change the test to not check deleted_at
# Instead of:
assert note.deleted_at is None
# Use:
# (No check - just verify note exists)
assert note is not None
Pros:
- Minimal code change (one line)
- Maintains current encapsulation
Cons:
- Weakens test coverage: Can't verify note is truly not soft-deleted
- Doesn't solve root problem: Future code will hit the same issue
- Violates test intent: Test specifically wants to verify deletion status
Verdict: REJECTED - This is a band-aid, not a fix
Alternative 2: Add Helper Property Instead of Raw Field
Approach: Keep deleted_at hidden, add is_deleted property
@dataclass(frozen=True)
class Note:
# ... existing fields ...
_deleted_at: Optional[datetime] = field(default=None, repr=False)
@property
def is_deleted(self) -> bool:
"""Check if note is soft-deleted"""
return self._deleted_at is not None
Pros:
- Provides boolean flag for deletion status
- Hides timestamp implementation detail
- Encapsulates deletion logic
Cons:
- Information loss: Tests/admin UIs can't see when note was deleted
- Inconsistent with other models: Session, Token, AuthState all expose timestamps
- More complex: Two fields instead of one
- Harder to serialize: Can't include deletion timestamp in API responses
Verdict: REJECTED - Adds complexity without clear benefit
Alternative 3: Create Separate SoftDeletedNote Model
Approach: Use different model classes for active vs deleted notes
Pros:
- Type safety: Can't accidentally mix active and deleted notes
- Clear separation of concerns
Cons:
- Massive complexity increase: Two model classes, complex query logic
- Violates simplicity principle: Way over-engineered for the problem
- Breaks existing code: Would require rewriting note operations
Verdict: REJECTED - Far too complex for V1
Risk Assessment
Risk Level: LOW
Implementation Risks:
- Breaking Changes: None - field is optional and nullable
- Performance Impact: None - no additional queries or processing
- Security Impact: None - field is read-only from model perspective
Migration Risks:
- Database Migration: None needed - column already exists
- Data Backfill: None needed - existing notes have NULL by default
- API Compatibility: Potential change if
to_dict()includesdeleted_at- Mitigation: Make inclusion optional or conditional
Summary for Developer
What to do:
- Add
deleted_at: Optional[datetime] = Noneto Note dataclass - Update
from_row()to extract and parsedeleted_at - Update docstring to document the field
- Run test suite to verify fix
Why:
- Database has
deleted_atcolumn but model doesn't expose it - Tests need to verify soft-deletion status
- Models should accurately reflect database schema
Complexity: LOW (3 lines of code change)
Time Estimate: 5 minutes implementation + 2 minutes testing
Files to modify:
starpunk/models.py(primary change)- No migration needed (database already has column)
- No test changes needed (test is already correct)
References
- Database Schema:
/home/phil/Projects/starpunk/starpunk/database.py:11-27 - Note Model:
/home/phil/Projects/starpunk/starpunk/models.py:44-440 - Notes Module:
/home/phil/Projects/starpunk/starpunk/notes.py:685-849 - Failing Test:
/home/phil/Projects/starpunk/tests/test_routes_admin.py:435-441
Next Steps:
- Review this analysis with development team
- Get approval for recommended fix
- Implement changes to
starpunk/models.py - Verify test passes
- Document decision in ADR if desired