Files
StarPunk/docs/reports/implementation-guide-expose-deleted-at.md
Phil Skentelbery 0cca8169ce 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>
2025-11-18 23:01:53 -07:00

263 lines
7.8 KiB
Markdown

# Implementation Guide: Expose deleted_at in Note Model
**Date**: 2025-11-18
**Issue**: Test `test_delete_without_confirmation_cancels` fails with `AttributeError: 'Note' object has no attribute 'deleted_at'`
**Decision**: ADR-013 - Expose deleted_at Field in Note Model
**Complexity**: LOW (3-4 line changes)
**Time Estimate**: 5 minutes implementation + 2 minutes testing
---
## Quick Summary
The `deleted_at` column exists in the database but is not exposed in the `Note` dataclass. This creates a model-schema mismatch that prevents tests from verifying soft-deletion status.
**Fix**: Add `deleted_at: Optional[datetime] = None` to the Note model.
---
## Implementation Steps
### Step 1: Add Field to Note Dataclass
**File**: `starpunk/models.py`
**Location**: Around line 109
**Change**:
```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)
```
### Step 2: Extract deleted_at in from_row()
**File**: `starpunk/models.py`
**Location**: Around line 145-162 in `from_row()` method
**Add timestamp conversion** (after `updated_at` conversion):
```python
# 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"))
```
**Update return statement** (add `deleted_at` parameter):
```python
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"),
)
```
### Step 3: Update Docstring
**File**: `starpunk/models.py`
**Location**: Around line 60 in Note docstring
**Add to Attributes section**:
```python
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)
```
### Step 4 (Optional): Include in to_dict() Serialization
**File**: `starpunk/models.py`
**Location**: Around line 389-398 in `to_dict()` method
**Add after excerpt** (optional, for API consistency):
```python
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")
```
---
## Testing
### Run Failing Test
```bash
uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -v
```
**Expected**: Test should PASS
### Run Full Test Suite
```bash
uv run pytest
```
**Expected**: All tests should pass with no regressions
### Manual Verification (Optional)
```python
from starpunk.notes import get_note, create_note, delete_note
# Create a test note
note = create_note("Test content", published=False)
# Verify deleted_at is None for active notes
assert note.deleted_at is None
# Soft delete the note
delete_note(slug=note.slug, soft=True)
# Note: get_note() filters out soft-deleted notes by default
# To verify deletion timestamp, query database directly:
from starpunk.database import get_db
from flask import current_app
db = get_db(current_app)
row = db.execute("SELECT * FROM notes WHERE slug = ?", (note.slug,)).fetchone()
assert row["deleted_at"] is not None # Should have timestamp
```
---
## Complete Diff
Here's the complete change summary:
**starpunk/models.py**:
```diff
@@ -44,6 +44,7 @@ class Note:
slug: str
file_path: str
published: bool
created_at: datetime
updated_at: datetime
+ deleted_at: Optional[datetime] = None
@@ -60,6 +61,7 @@ class Note:
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)
content_hash: SHA-256 hash of content (for integrity checking)
@@ -150,6 +152,10 @@ def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
+ 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"],
@@ -157,6 +163,7 @@ def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note":
published=bool(data["published"]),
created_at=created_at,
updated_at=updated_at,
+ deleted_at=deleted_at,
_data_dir=data_dir,
content_hash=data.get("content_hash"),
)
```
---
## Verification Checklist
After implementation, verify:
- [ ] `deleted_at` field exists in Note dataclass
- [ ] Field has type `Optional[datetime]` with default `None`
- [ ] `from_row()` extracts `deleted_at` from database rows
- [ ] `from_row()` handles ISO string format timestamps
- [ ] `from_row()` handles None values (active notes)
- [ ] Docstring documents the `deleted_at` field
- [ ] Test `test_delete_without_confirmation_cancels` passes
- [ ] Full test suite passes
- [ ] No import errors (datetime and Optional already imported)
---
## Why This Fix Is Correct
1. **Root Cause**: Model-schema mismatch - database has `deleted_at` but model doesn't expose it
2. **Principle**: Data models should faithfully represent database schema
3. **Testability**: Tests need to verify soft-deletion behavior
4. **Simplicity**: One field addition, minimal complexity
5. **Backwards Compatible**: Optional field won't break existing code
---
## References
- **ADR**: `/home/phil/Projects/starpunk/docs/decisions/ADR-013-expose-deleted-at-in-note-model.md`
- **Analysis**: `/home/phil/Projects/starpunk/docs/reports/test-failure-analysis-deleted-at-attribute.md`
- **File to Edit**: `/home/phil/Projects/starpunk/starpunk/models.py`
- **Test File**: `/home/phil/Projects/starpunk/tests/test_routes_admin.py`
---
## Questions?
**Q: Why not hide this field?**
A: Transparency wins for data models. Tests and admin UIs need access to deletion status.
**Q: Will this break existing code?**
A: No. The field is optional (nullable), so existing code continues to work.
**Q: Why not use `is_deleted` property instead?**
A: That would lose the deletion timestamp information, which is valuable for debugging and admin UIs.
**Q: Do I need a database migration?**
A: No. The `deleted_at` column already exists in the database schema.
---
**Ready to implement? The changes are minimal and low-risk.**