## 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>
489 lines
15 KiB
Markdown
489 lines
15 KiB
Markdown
# 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:
|
|
|
|
```sql
|
|
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_at` is defined as a nullable TIMESTAMP column
|
|
- An index exists on `deleted_at` for 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`:
|
|
|
|
```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
|
|
|
|
# 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_at` is completely absent from the dataclass definition
|
|
- The `from_row()` class method (line 123-162) does not extract `deleted_at` from database rows
|
|
|
|
### 3. Notes Module Review
|
|
|
|
**File**: `starpunk/notes.py`
|
|
|
|
The notes module **uses** `deleted_at` in queries but **never exposes** it:
|
|
|
|
```python
|
|
# 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_at` to 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:
|
|
|
|
```python
|
|
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_at` is accessible on the Note model
|
|
- Without the field, the test cannot verify soft-deletion status
|
|
|
|
---
|
|
|
|
## Architectural Assessment
|
|
|
|
### Why This Is a Problem
|
|
|
|
1. **Model-Schema Mismatch**: The fundamental rule of data models is that they should accurately represent the database schema. Currently, `Note` is incomplete.
|
|
|
|
2. **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**.
|
|
|
|
3. **Testing Limitation**: Tests cannot verify soft-deletion behavior without accessing the field. This creates a testing blind spot.
|
|
|
|
4. **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:
|
|
|
|
1. **Query-Level Filtering**: The developer chose to filter soft-deleted notes at the **query level** (`WHERE deleted_at IS NULL`), making `deleted_at` invisible to consumers.
|
|
|
|
2. **Encapsulation**: This follows a pattern of "consumers shouldn't need to know about deletion mechanics" - they just get active notes.
|
|
|
|
3. **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
|
|
|
|
1. **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
|
|
|
|
2. **Data Integrity**:
|
|
- The model should be a **faithful representation** of the database
|
|
- Hiding fields creates a semantic mismatch
|
|
- **Verdict**: Add the field
|
|
|
|
3. **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**:
|
|
|
|
1. **Principle of Least Surprise**: If a database column exists, developers expect to access it through the model
|
|
|
|
2. **Testability**: Tests must be able to verify soft-deletion state
|
|
|
|
3. **Transparency**: Data models should accurately reflect database schema
|
|
|
|
4. **Future Flexibility**: Admin UIs, backup tools, and debugging features will need this field
|
|
|
|
5. **Low Complexity Cost**: Adding one optional field is minimal complexity
|
|
|
|
6. **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`
|
|
|
|
1. Add `deleted_at` field to Note dataclass (line ~109):
|
|
|
|
```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
|
|
|
|
# Internal fields (not from database)
|
|
_data_dir: Path = field(repr=False, compare=False)
|
|
|
|
# Optional fields
|
|
content_hash: Optional[str] = None
|
|
```
|
|
|
|
2. Update `from_row()` class method to extract `deleted_at` (line ~145-162):
|
|
|
|
```python
|
|
@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"),
|
|
)
|
|
```
|
|
|
|
3. (Optional) Update `to_dict()` method to include `deleted_at` when serializing (line ~354-406):
|
|
|
|
```python
|
|
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 ...
|
|
```
|
|
|
|
4. Update docstring to document the field (line ~44-100):
|
|
|
|
```python
|
|
@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**:
|
|
|
|
1. Verify `Note.from_row()` correctly parses `deleted_at` from database rows
|
|
2. Verify `deleted_at` defaults to `None` for active notes
|
|
3. Verify `deleted_at` is set to timestamp for soft-deleted notes
|
|
4. Verify `to_dict()` includes `deleted_at` when present
|
|
|
|
**Integration Tests**:
|
|
|
|
1. The failing test should pass: `test_delete_without_confirmation_cancels`
|
|
2. Verify soft-deleted notes have `deleted_at` set after `delete_note(soft=True)`
|
|
3. Verify `get_note()` returns `None` for soft-deleted notes (existing behavior)
|
|
4. Verify hard-deleted notes are removed entirely (existing behavior)
|
|
|
|
**Regression Tests**:
|
|
|
|
1. Run full test suite to ensure no existing tests break
|
|
2. Verify `list_notes()` still excludes soft-deleted notes
|
|
3. Verify `get_note()` still excludes soft-deleted notes
|
|
|
|
### Acceptance Criteria
|
|
|
|
- [ ] `deleted_at` field added to Note dataclass
|
|
- [ ] `from_row()` extracts `deleted_at` from database rows
|
|
- [ ] `from_row()` handles `deleted_at` as string (ISO format)
|
|
- [ ] `from_row()` handles `deleted_at` as None (active notes)
|
|
- [ ] Docstring updated to document `deleted_at`
|
|
- [ ] Test `test_delete_without_confirmation_cancels` passes
|
|
- [ ] 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`
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
@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()` includes `deleted_at`
|
|
- **Mitigation**: Make inclusion optional or conditional
|
|
|
|
---
|
|
|
|
## Summary for Developer
|
|
|
|
**What to do**:
|
|
1. Add `deleted_at: Optional[datetime] = None` to Note dataclass
|
|
2. Update `from_row()` to extract and parse `deleted_at`
|
|
3. Update docstring to document the field
|
|
4. Run test suite to verify fix
|
|
|
|
**Why**:
|
|
- Database has `deleted_at` column 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**:
|
|
1. Review this analysis with development team
|
|
2. Get approval for recommended fix
|
|
3. Implement changes to `starpunk/models.py`
|
|
4. Verify test passes
|
|
5. Document decision in ADR if desired
|