feat(tags): Add database schema and tags module (v1.3.0 Phase 1)
Implements tag/category system backend following microformats2 p-category specification. Database changes: - Migration 008: Add tags and note_tags tables - Normalized tag storage (case-insensitive lookup, display name preserved) - Indexes for performance New module: - starpunk/tags.py: Tag management functions - normalize_tag: Normalize tag strings - get_or_create_tag: Get or create tag records - add_tags_to_note: Associate tags with notes (replaces existing) - get_note_tags: Retrieve note tags (alphabetically ordered) - get_tag_by_name: Lookup tag by normalized name - get_notes_by_tag: Get all notes with specific tag - parse_tag_input: Parse comma-separated tag input Model updates: - Note.tags property (lazy-loaded, prefer pre-loading in routes) - Note.to_dict() add include_tags parameter CRUD updates: - create_note() accepts tags parameter - update_note() accepts tags parameter (None = no change, [] = remove all) Micropub integration: - Pass tags to create_note() (tags already extracted by extract_tags()) - Return tags in q=source response Per design doc: docs/design/v1.3.0/microformats-tags-design.md Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
262
docs/design/v1.0.0/implementation-guide-expose-deleted-at.md
Normal file
262
docs/design/v1.0.0/implementation-guide-expose-deleted-at.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# 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.**
|
||||
Reference in New Issue
Block a user