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:
452
docs/reports/delete-route-implementation-spec.md
Normal file
452
docs/reports/delete-route-implementation-spec.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Delete Route Implementation Specification
|
||||
|
||||
**Date**: 2025-11-18
|
||||
**Component**: Admin Routes - Delete Note
|
||||
**File**: `/home/phil/Projects/starpunk/starpunk/routes/admin.py`
|
||||
**Function**: `delete_note_submit()` (lines 173-206)
|
||||
**ADR**: ADR-012 (HTTP Error Handling Policy)
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### Objective
|
||||
|
||||
Modify the delete route to check note existence before deletion and return HTTP 404 with an error message when attempting to delete a nonexistent note.
|
||||
|
||||
## Exact Code Change
|
||||
|
||||
### Current Implementation (Lines 173-206)
|
||||
|
||||
```python
|
||||
@bp.route("/delete/<int:note_id>", methods=["POST"])
|
||||
@require_auth
|
||||
def delete_note_submit(note_id: int):
|
||||
"""
|
||||
Handle note deletion
|
||||
|
||||
Deletes a note after confirmation.
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
note_id: Database ID of note to delete
|
||||
|
||||
Form data:
|
||||
confirm: Must be 'yes' to proceed with deletion
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard with success/error message
|
||||
|
||||
Decorator: @require_auth
|
||||
"""
|
||||
# Check for confirmation
|
||||
if request.form.get("confirm") != "yes":
|
||||
flash("Deletion cancelled", "info")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
try:
|
||||
delete_note(id=note_id, soft=False)
|
||||
flash("Note deleted successfully", "success")
|
||||
except ValueError as e:
|
||||
flash(f"Error deleting note: {e}", "error")
|
||||
except Exception as e:
|
||||
flash(f"Unexpected error deleting note: {e}", "error")
|
||||
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
```
|
||||
|
||||
### Required Implementation (Lines 173-206)
|
||||
|
||||
```python
|
||||
@bp.route("/delete/<int:note_id>", methods=["POST"])
|
||||
@require_auth
|
||||
def delete_note_submit(note_id: int):
|
||||
"""
|
||||
Handle note deletion
|
||||
|
||||
Deletes a note after confirmation.
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
note_id: Database ID of note to delete
|
||||
|
||||
Form data:
|
||||
confirm: Must be 'yes' to proceed with deletion
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard with success/error message
|
||||
|
||||
Decorator: @require_auth
|
||||
"""
|
||||
# Check if note exists first (per ADR-012)
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
|
||||
# Check for confirmation
|
||||
if request.form.get("confirm") != "yes":
|
||||
flash("Deletion cancelled", "info")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
try:
|
||||
delete_note(id=note_id, soft=False)
|
||||
flash("Note deleted successfully", "success")
|
||||
except ValueError as e:
|
||||
flash(f"Error deleting note: {e}", "error")
|
||||
except Exception as e:
|
||||
flash(f"Unexpected error deleting note: {e}", "error")
|
||||
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
```
|
||||
|
||||
## Line-by-Line Changes
|
||||
|
||||
### Insert After Line 192 (after docstring, before confirmation check)
|
||||
|
||||
**Add these 4 lines**:
|
||||
|
||||
```python
|
||||
# Check if note exists first (per ADR-012)
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
|
||||
```
|
||||
|
||||
**Result**: Lines shift down by 5 (including blank line)
|
||||
|
||||
### No Other Changes Required
|
||||
|
||||
- Docstring: No changes
|
||||
- Confirmation check: No changes (shifts to line 199)
|
||||
- Deletion logic: No changes (shifts to line 203)
|
||||
- Return statement: No changes (shifts to line 211)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Function Call: `get_note(id=note_id, load_content=False)`
|
||||
|
||||
**Purpose**: Check if note exists in database
|
||||
|
||||
**Parameters**:
|
||||
- `id=note_id`: Look up by database ID (primary key)
|
||||
- `load_content=False`: Metadata only (no file I/O)
|
||||
|
||||
**Returns**:
|
||||
- `Note` object if found
|
||||
- `None` if not found or soft-deleted
|
||||
|
||||
**Performance**: ~0.1ms (single SELECT query)
|
||||
|
||||
### Flash Message: `"Note not found"`
|
||||
|
||||
**Purpose**: User-facing error message
|
||||
|
||||
**Category**: `"error"` (red alert in UI)
|
||||
|
||||
**Why this wording**:
|
||||
- Consistent with edit route (line 151)
|
||||
- Simple and clear
|
||||
- Test checks for "not found" substring
|
||||
- ADR-012 example uses this exact message
|
||||
|
||||
### Return Statement: `return redirect(url_for("admin.dashboard")), 404`
|
||||
|
||||
**Purpose**: Return HTTP 404 with redirect
|
||||
|
||||
**Flask Pattern**: Tuple `(response, status_code)`
|
||||
- First element: Response object (redirect)
|
||||
- Second element: HTTP status code (404)
|
||||
|
||||
**Result**:
|
||||
- HTTP 404 status sent to client
|
||||
- Location header: `/admin/`
|
||||
- Flash message stored in session
|
||||
- Client can follow redirect to see error
|
||||
|
||||
**Why not just return 404 error page**:
|
||||
- Better UX (user sees dashboard with error, not blank 404 page)
|
||||
- Consistent with update route pattern
|
||||
- Per ADR-012: "404 responses MAY redirect to a safe location"
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
### Before Implementing
|
||||
|
||||
- [ ] Read ADR-012 (HTTP Error Handling Policy)
|
||||
- [ ] Review similar implementation in `update_note_submit()` (line 148-152)
|
||||
- [ ] Verify `get_note` is imported (line 15 - already imported ✅)
|
||||
- [ ] Verify test expectations in `test_delete_nonexistent_note_shows_error`
|
||||
|
||||
### After Implementing
|
||||
|
||||
- [ ] Code follows exact pattern from update route
|
||||
- [ ] Existence check happens BEFORE confirmation check
|
||||
- [ ] Flash message is "Note not found" with category "error"
|
||||
- [ ] Return statement includes 404 status code
|
||||
- [ ] No other logic changed
|
||||
- [ ] Imports unchanged (get_note already imported)
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Run failing test: `uv run pytest tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error -v`
|
||||
- [ ] Verify test now passes
|
||||
- [ ] Run all delete route tests: `uv run pytest tests/test_routes_admin.py::TestAdminDeleteRoutes -v`
|
||||
- [ ] Verify all tests still pass (no regressions)
|
||||
- [ ] Run full admin route tests: `uv run pytest tests/test_routes_admin.py -v`
|
||||
- [ ] Verify 406/406 tests pass
|
||||
|
||||
## Expected Test Results
|
||||
|
||||
### Before Fix
|
||||
|
||||
```
|
||||
FAILED tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error
|
||||
AssertionError: assert False
|
||||
+ where False = (b'error' in b'...deleted successfully...' or b'not found' in b'...')
|
||||
```
|
||||
|
||||
### After Fix
|
||||
|
||||
```
|
||||
PASSED tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error
|
||||
```
|
||||
|
||||
## Edge Cases Handled
|
||||
|
||||
### Case 1: Note Exists
|
||||
|
||||
**Scenario**: User deletes existing note
|
||||
**Behavior**:
|
||||
1. Existence check passes (note found)
|
||||
2. Confirmation check (if confirmed, proceed)
|
||||
3. Deletion succeeds
|
||||
4. Flash: "Note deleted successfully"
|
||||
5. Return: 302 redirect
|
||||
|
||||
**Test Coverage**: `test_delete_redirects_to_dashboard`
|
||||
|
||||
### Case 2: Note Doesn't Exist
|
||||
|
||||
**Scenario**: User deletes nonexistent note (ID 99999)
|
||||
**Behavior**:
|
||||
1. Existence check fails (note not found)
|
||||
2. Flash: "Note not found"
|
||||
3. Return: 404 with redirect (no deletion attempted)
|
||||
|
||||
**Test Coverage**: `test_delete_nonexistent_note_shows_error` ← This fix
|
||||
|
||||
### Case 3: Note Soft-Deleted
|
||||
|
||||
**Scenario**: User deletes note that was already soft-deleted
|
||||
**Behavior**:
|
||||
1. `get_note()` excludes soft-deleted notes (WHERE deleted_at IS NULL)
|
||||
2. Existence check fails (note not found from user perspective)
|
||||
3. Flash: "Note not found"
|
||||
4. Return: 404 with redirect
|
||||
|
||||
**Test Coverage**: Covered by `get_note()` behavior (implicit)
|
||||
|
||||
### Case 4: Deletion Not Confirmed
|
||||
|
||||
**Scenario**: User submits form without `confirm=yes`
|
||||
**Behavior**:
|
||||
1. Existence check passes (note found)
|
||||
2. Confirmation check fails
|
||||
3. Flash: "Deletion cancelled"
|
||||
4. Return: 302 redirect (no deletion, no 404)
|
||||
|
||||
**Test Coverage**: Existing tests (no change)
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Database Queries
|
||||
|
||||
**Before**:
|
||||
1. DELETE query (inside delete_note)
|
||||
|
||||
**After**:
|
||||
1. SELECT query (get_note - existence check)
|
||||
2. DELETE query (inside delete_note)
|
||||
|
||||
**Overhead**: ~0.1ms per deletion request
|
||||
|
||||
### Why This is Acceptable
|
||||
|
||||
1. Single-user system (not high traffic)
|
||||
2. Deletions are rare operations
|
||||
3. Correctness > performance for edge cases
|
||||
4. Consistent with update route (already accepts this overhead)
|
||||
5. `load_content=False` avoids file I/O (only metadata query)
|
||||
|
||||
## Consistency with Other Routes
|
||||
|
||||
### Edit Routes (Already Implemented)
|
||||
|
||||
**GET /admin/edit/<id>** (line 118-122):
|
||||
```python
|
||||
note = get_note(id=note_id)
|
||||
|
||||
if not note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
```
|
||||
|
||||
**POST /admin/edit/<id>** (line 148-152):
|
||||
```python
|
||||
# Check if note exists first
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
```
|
||||
|
||||
### Delete Route (This Implementation)
|
||||
|
||||
**POST /admin/delete/<id>** (new lines 193-197):
|
||||
```python
|
||||
# Check if note exists first (per ADR-012)
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
```
|
||||
|
||||
**Pattern Consistency**: ✅ 100% identical to update route
|
||||
|
||||
## ADR-012 Compliance
|
||||
|
||||
### Required Elements
|
||||
|
||||
| Requirement | Status |
|
||||
|-------------|--------|
|
||||
| Return 404 for nonexistent resource | ✅ Yes (`return ..., 404`) |
|
||||
| Check existence before operation | ✅ Yes (`get_note()` before `delete_note()`) |
|
||||
| Include user-friendly flash message | ✅ Yes (`flash("Note not found", "error")`) |
|
||||
| Redirect to safe location | ✅ Yes (`redirect(url_for("admin.dashboard"))`) |
|
||||
|
||||
### Implementation Pattern (ADR-012, lines 56-74)
|
||||
|
||||
**Spec Pattern**:
|
||||
```python
|
||||
@bp.route("/operation/<int:resource_id>", methods=["GET", "POST"])
|
||||
@require_auth
|
||||
def operation(resource_id: int):
|
||||
# 1. CHECK EXISTENCE FIRST
|
||||
resource = get_resource(id=resource_id)
|
||||
if not resource:
|
||||
flash("Resource not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404 # ← MUST return 404
|
||||
# ...
|
||||
```
|
||||
|
||||
**Our Implementation**: ✅ Follows pattern exactly
|
||||
|
||||
## Common Implementation Mistakes to Avoid
|
||||
|
||||
### Mistake 1: Check Existence After Confirmation
|
||||
|
||||
**Wrong**:
|
||||
```python
|
||||
# Check for confirmation
|
||||
if request.form.get("confirm") != "yes":
|
||||
# ...
|
||||
|
||||
# Check if note exists ← TOO LATE
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
```
|
||||
|
||||
**Why Wrong**: User confirms deletion of nonexistent note, then gets 404
|
||||
|
||||
**Correct**: Check existence FIRST (before any user interaction)
|
||||
|
||||
### Mistake 2: Forget load_content=False
|
||||
|
||||
**Wrong**:
|
||||
```python
|
||||
existing_note = get_note(id=note_id) # Loads file content
|
||||
```
|
||||
|
||||
**Why Wrong**: Unnecessary file I/O (we only need to check existence)
|
||||
|
||||
**Correct**: `get_note(id=note_id, load_content=False)` (metadata only)
|
||||
|
||||
### Mistake 3: Return 302 Instead of 404
|
||||
|
||||
**Wrong**:
|
||||
```python
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")) # ← Missing 404
|
||||
```
|
||||
|
||||
**Why Wrong**: Returns HTTP 302 (redirect), not 404 (not found)
|
||||
|
||||
**Correct**: `return redirect(...), 404` (tuple with status code)
|
||||
|
||||
### Mistake 4: Wrong Flash Message Category
|
||||
|
||||
**Wrong**:
|
||||
```python
|
||||
flash("Note not found", "info") # ← Should be "error"
|
||||
```
|
||||
|
||||
**Why Wrong**: Not an error in UI (blue alert, not red)
|
||||
|
||||
**Correct**: `flash("Note not found", "error")` (red error alert)
|
||||
|
||||
### Mistake 5: Catching NoteNotFoundError from delete_note()
|
||||
|
||||
**Wrong**:
|
||||
```python
|
||||
try:
|
||||
delete_note(id=note_id, soft=False)
|
||||
except NoteNotFoundError: # ← delete_note doesn't raise this
|
||||
flash("Note not found", "error")
|
||||
return redirect(...), 404
|
||||
```
|
||||
|
||||
**Why Wrong**:
|
||||
- `delete_note()` is idempotent (doesn't raise on missing note)
|
||||
- Existence check should happen BEFORE calling delete_note
|
||||
- Violates separation of concerns (route layer vs data layer)
|
||||
|
||||
**Correct**: Explicit existence check before deletion (as specified)
|
||||
|
||||
## Final Verification
|
||||
|
||||
### Code Review Checklist
|
||||
|
||||
- [ ] Existence check is first operation (after docstring)
|
||||
- [ ] Uses `get_note(id=note_id, load_content=False)` exactly
|
||||
- [ ] Flash message is `"Note not found"` with category `"error"`
|
||||
- [ ] Return statement is `return redirect(url_for("admin.dashboard")), 404`
|
||||
- [ ] No changes to confirmation logic
|
||||
- [ ] No changes to deletion logic
|
||||
- [ ] No changes to exception handling
|
||||
- [ ] No changes to imports
|
||||
- [ ] Code matches update route pattern exactly
|
||||
|
||||
### Test Validation
|
||||
|
||||
1. Run specific test: Should PASS
|
||||
2. Run delete route tests: All should PASS
|
||||
3. Run admin route tests: All should PASS (406/406)
|
||||
4. Run full test suite: All should PASS
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] This implementation spec reviewed
|
||||
- [ ] Root cause analysis document reviewed
|
||||
- [ ] ADR-012 referenced and understood
|
||||
- [ ] Changes documented in changelog
|
||||
- [ ] Version incremented per versioning strategy
|
||||
|
||||
## Summary
|
||||
|
||||
**Change**: Add 4 lines (existence check + error handling)
|
||||
**Location**: After line 192, before confirmation check
|
||||
**Impact**: 1 test changes from FAIL to PASS
|
||||
**Result**: 406/406 tests passing ✅
|
||||
|
||||
This is the minimal, correct implementation that complies with ADR-012 and maintains consistency with existing routes.
|
||||
Reference in New Issue
Block a user