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>
This commit is contained in:
383
docs/decisions/ADR-013-expose-deleted-at-in-note-model.md
Normal file
383
docs/decisions/ADR-013-expose-deleted-at-in-note-model.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# 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`):
|
||||
```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, -- Column exists
|
||||
content_hash TEXT
|
||||
);
|
||||
```
|
||||
|
||||
**Note Model** (`starpunk/models.py`):
|
||||
```python
|
||||
@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_at` in queries (`WHERE deleted_at IS NULL`)
|
||||
- Sets `deleted_at` during soft deletion (`UPDATE notes SET deleted_at = ?`)
|
||||
- Never exposes the value through the model layer
|
||||
|
||||
### Problem
|
||||
|
||||
This architecture creates several issues:
|
||||
|
||||
1. **Testability Gap**: Tests cannot verify soft-deletion status because `note.deleted_at` doesn't exist
|
||||
2. **Information Hiding**: The model hides database state from consumers
|
||||
3. **Principle Violation**: Data models should faithfully represent database schema
|
||||
4. **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`:
|
||||
```python
|
||||
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
|
||||
|
||||
1. **Transparency Over Encapsulation**
|
||||
- For data models, transparency should win
|
||||
- Developers expect to access any database column through the model
|
||||
- Hiding fields creates semantic mismatches
|
||||
|
||||
2. **Testability**
|
||||
- Tests must be able to verify soft-deletion behavior
|
||||
- Current design makes deletion status verification impossible
|
||||
- Exposing the field enables proper test coverage
|
||||
|
||||
3. **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
|
||||
|
||||
4. **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
|
||||
|
||||
5. **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
|
||||
|
||||
1. **Test Suite Passes**: `test_delete_without_confirmation_cancels` will pass
|
||||
2. **Complete Model**: Note model accurately reflects database schema
|
||||
3. **Better Testability**: All tests can verify soft-deletion state
|
||||
4. **Future-Proof**: Admin UIs and debugging tools have access to deletion data
|
||||
5. **Consistency**: All models expose their database fields
|
||||
|
||||
### Negative Consequences
|
||||
|
||||
1. **Loss of Encapsulation**: Consumers now see `deleted_at` and must understand soft deletion
|
||||
- **Mitigation**: Document the field clearly in docstring
|
||||
- **Impact**: Minimal - developers working with notes should understand deletion
|
||||
|
||||
2. **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_at` continues to work
|
||||
- `Note.from_row()` sets it to `None` for active notes
|
||||
- Serialization is optional
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
### File: `starpunk/models.py`
|
||||
|
||||
#### Change 1: Add Field to Dataclass
|
||||
```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)
|
||||
|
||||
# Optional fields
|
||||
content_hash: Optional[str] = None
|
||||
```
|
||||
|
||||
#### Change 2: Update from_row() Method
|
||||
|
||||
Add timestamp conversion for `deleted_at`:
|
||||
|
||||
```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 LINE
|
||||
_data_dir=data_dir,
|
||||
content_hash=data.get("content_hash"),
|
||||
)
|
||||
```
|
||||
|
||||
#### Change 3: Update Docstring
|
||||
|
||||
Add documentation for `deleted_at`:
|
||||
|
||||
```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 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:
|
||||
|
||||
```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)
|
||||
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
|
||||
|
||||
1. **Run Failing Test**:
|
||||
```bash
|
||||
uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -v
|
||||
```
|
||||
Should pass after changes.
|
||||
|
||||
2. **Run Full Test Suite**:
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
Should pass with no regressions.
|
||||
|
||||
3. **Manual Verification**:
|
||||
```python
|
||||
# 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_at` is `None` for active notes
|
||||
- `deleted_at` is `None` for newly created notes
|
||||
- `deleted_at` is set after soft deletion (verify via database query)
|
||||
- `get_note()` returns `None` for soft-deleted notes (existing behavior)
|
||||
- `list_notes()` excludes soft-deleted notes (existing behavior)
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] `deleted_at` field added to Note dataclass
|
||||
- [ ] `from_row()` extracts and parses `deleted_at` from database rows
|
||||
- [ ] `from_row()` handles `deleted_at` as ISO string
|
||||
- [ ] `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 with no regressions
|
||||
- [ ] Optional: `to_dict()` includes `deleted_at` when 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
|
||||
Reference in New Issue
Block a user