# 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.**
