# Phase 1.2 Quick Reference: Data Models

## Quick Start

**File**: `starpunk/models.py`
**Tests**: `tests/test_models.py`
**Estimated Time**: 3-4 hours
**Dependencies**: `starpunk/utils.py`, `starpunk/database.py`

## Implementation Order

1. Module docstring, imports, and constants
2. Note model (most complex)
3. Session model
4. Token model
5. AuthState model (simplest)
6. Tests for all models

## Model Checklist

### Note Model (10 items)
- [ ] Dataclass structure with all fields
- [ ] `from_row(row: dict, data_dir: Path) -> Note` class method
- [ ] `content` property (lazy loading from file)
- [ ] `html` property (markdown rendering + caching)
- [ ] `title` property (extract from content)
- [ ] `excerpt` property (first 200 chars)
- [ ] `permalink` property (URL path)
- [ ] `is_published` property (alias)
- [ ] `to_dict(include_content, include_html) -> dict` method
- [ ] `verify_integrity() -> bool` method

### Session Model (6 items)
- [ ] Dataclass structure with all fields
- [ ] `from_row(row: dict) -> Session` class method
- [ ] `is_expired` property
- [ ] `is_valid() -> bool` method
- [ ] `with_updated_last_used() -> Session` method
- [ ] `to_dict() -> dict` method

### Token Model (6 items)
- [ ] Dataclass structure with all fields
- [ ] `from_row(row: dict) -> Token` class method
- [ ] `scopes` property (list of scope strings)
- [ ] `has_scope(required_scope: str) -> bool` method
- [ ] `is_valid(required_scope: Optional[str]) -> bool` method
- [ ] `to_dict() -> dict` method

### AuthState Model (4 items)
- [ ] Dataclass structure with all fields
- [ ] `from_row(row: dict) -> AuthState` class method
- [ ] `is_expired` property
- [ ] `is_valid() -> bool` method
- [ ] `to_dict() -> dict` method

**Total**: 4 models, 26 methods/properties

## Constants Required

```python
# Session configuration
DEFAULT_SESSION_EXPIRY_DAYS = 30
SESSION_EXTENSION_ON_USE = True

# Auth state configuration
DEFAULT_AUTH_STATE_EXPIRY_MINUTES = 5

# Token configuration
DEFAULT_TOKEN_EXPIRY_DAYS = 90

# Markdown rendering
MARKDOWN_EXTENSIONS = ['extra', 'codehilite', 'nl2br']

# Content limits
MAX_TITLE_LENGTH = 200
EXCERPT_LENGTH = 200
```

## Key Design Patterns

### Frozen Dataclasses

```python
@dataclass(frozen=True)
class Note:
    # Core fields
    id: int
    slug: str
    # ... more fields

    # Internal fields (not from database)
    _data_dir: Path = field(repr=False, compare=False)
    _cached_content: Optional[str] = field(
        default=None,
        repr=False,
        compare=False,
        init=False
    )
```

### Lazy Loading Pattern

```python
@property
def content(self) -> str:
    """Lazy-load content from file"""
    if self._cached_content is None:
        # Read from file
        file_path = self._data_dir / self.file_path
        content = read_note_file(file_path)
        # Cache it (use object.__setattr__ for frozen dataclass)
        object.__setattr__(self, '_cached_content', content)
    return self._cached_content
```

### from_row Pattern

```python
@classmethod
def from_row(cls, row: dict, data_dir: Path = None) -> 'Note':
    """Create instance from database row"""
    # Handle sqlite3.Row or dict
    if hasattr(row, 'keys'):
        data = {key: row[key] for key in row.keys()}
    else:
        data = row

    # Convert timestamps if needed
    if isinstance(data['created_at'], str):
        data['created_at'] = datetime.fromisoformat(data['created_at'])

    return cls(
        id=data['id'],
        slug=data['slug'],
        # ... more fields
        _data_dir=data_dir
    )
```

### Immutable Update Pattern

```python
def with_updated_last_used(self) -> 'Session':
    """Create new session with updated timestamp"""
    from dataclasses import replace
    return replace(self, last_used_at=datetime.utcnow())
```

## Test Coverage Requirements

- Minimum 90% code coverage
- Test all model creation (from_row)
- Test all properties and methods
- Test lazy loading behavior
- Test caching behavior
- Test edge cases (empty content, expired sessions, etc.)
- Test error cases (file not found, invalid data)

## Example Test Structure

```python
class TestNoteModel:
    def test_from_row(self): pass
    def test_content_lazy_loading(self, tmp_path): pass
    def test_content_caching(self, tmp_path): pass
    def test_html_rendering(self, tmp_path): pass
    def test_html_caching(self, tmp_path): pass
    def test_title_extraction(self, tmp_path): pass
    def test_title_fallback_to_slug(self): pass
    def test_excerpt_generation(self, tmp_path): pass
    def test_permalink(self): pass
    def test_to_dict_basic(self): pass
    def test_to_dict_with_content(self, tmp_path): pass
    def test_verify_integrity_success(self, tmp_path): pass
    def test_verify_integrity_failure(self, tmp_path): pass

class TestSessionModel:
    def test_from_row(self): pass
    def test_is_expired_false(self): pass
    def test_is_expired_true(self): pass
    def test_is_valid_active(self): pass
    def test_is_valid_expired(self): pass
    def test_with_updated_last_used(self): pass
    def test_to_dict(self): pass

class TestTokenModel:
    def test_from_row(self): pass
    def test_scopes_property(self): pass
    def test_scopes_empty(self): pass
    def test_has_scope_true(self): pass
    def test_has_scope_false(self): pass
    def test_is_expired_never(self): pass
    def test_is_expired_yes(self): pass
    def test_is_valid(self): pass
    def test_is_valid_with_scope(self): pass
    def test_to_dict(self): pass

class TestAuthStateModel:
    def test_from_row(self): pass
    def test_is_expired_false(self): pass
    def test_is_expired_true(self): pass
    def test_is_valid(self): pass
    def test_to_dict(self): pass
```

## Common Pitfalls to Avoid

1. **Don't modify frozen dataclasses directly** → Use `object.__setattr__()` for caching
2. **Don't load content in __init__** → Use lazy loading properties
3. **Don't forget to cache expensive operations** → HTML rendering should be cached
4. **Don't validate paths in models** → Models trust caller has validated
5. **Don't put business logic in models** → Models are data only
6. **Don't forget datetime conversion** → Database may return strings
7. **Don't expose sensitive data in to_dict()** → Exclude tokens, passwords
8. **Don't forget to test with tmp_path** → Use pytest tmp_path fixture

## Security Checklist

- [ ] Session token excluded from to_dict()
- [ ] Token value excluded from to_dict()
- [ ] File paths not directly exposed (use properties)
- [ ] Expiry checked before using sessions/tokens/states
- [ ] No SQL injection (models don't query, but be aware)
- [ ] File reading errors propagate (don't hide)
- [ ] Datetime comparisons use UTC

## Performance Targets

- Model creation (from_row): < 1ms
- Content loading (first access): < 5ms
- HTML rendering (first access): < 10ms
- Cached property access: < 0.1ms
- to_dict() serialization: < 1ms

## Module Structure Template

```python
"""
Data models for StarPunk

This module provides data model classes that wrap database rows and provide
clean interfaces for working with notes, sessions, tokens, and authentication
state. All models are immutable and use dataclasses.
"""

# Standard library
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

# Third-party
import markdown

# Local
from starpunk.utils import read_note_file, calculate_content_hash

# Constants
DEFAULT_SESSION_EXPIRY_DAYS = 30
# ... more constants

# Models

@dataclass(frozen=True)
class Note:
    """Represents a note/post"""
    # Fields here
    pass

@dataclass(frozen=True)
class Session:
    """Represents an authenticated session"""
    # Fields here
    pass

@dataclass(frozen=True)
class Token:
    """Represents a Micropub access token"""
    # Fields here
    pass

@dataclass(frozen=True)
class AuthState:
    """Represents an OAuth state token"""
    # Fields here
    pass
```

## Quick Algorithm Reference

### Title Extraction Algorithm

```
1. Get content (lazy load if needed)
2. Split on newlines
3. Take first non-empty line
4. Strip markdown heading syntax (# , ## , etc.)
5. Limit to MAX_TITLE_LENGTH
6. If empty, use slug as fallback
```

### Excerpt Generation Algorithm

```
1. Get content (lazy load if needed)
2. Remove markdown syntax (simple regex)
3. Take first EXCERPT_LENGTH characters
4. Truncate to word boundary
5. Add ellipsis if truncated
```

### Lazy Loading Algorithm

```
1. Check if cached value is None
2. If None:
   a. Perform expensive operation (file read, HTML render)
   b. Store result in cache using object.__setattr__()
3. Return cached value
```

### Session Validation Algorithm

```
1. Check if session is expired
2. Check if session_token is not empty
3. Check if 'me' URL is valid (basic check)
4. Return True only if all checks pass
```

### Token Scope Checking Algorithm

```
1. Parse scope string into list (split on whitespace)
2. Check if required_scope in list
3. Return boolean
```

## Database Row Format Reference

### Note Row

```python
{
    'id': 1,
    'slug': 'my-note',
    'file_path': 'notes/2024/11/my-note.md',
    'published': 1,  # or True
    'created_at': '2024-11-18T14:30:00' or datetime(...),
    'updated_at': '2024-11-18T14:30:00' or datetime(...),
    'content_hash': 'abc123...'
}
```

### Session Row

```python
{
    'id': 1,
    'session_token': 'xyz789...',
    'me': 'https://alice.example.com',
    'created_at': datetime(...),
    'expires_at': datetime(...),
    'last_used_at': datetime(...) or None
}
```

### Token Row

```python
{
    'token': 'abc123...',
    'me': 'https://alice.example.com',
    'client_id': 'https://quill.p3k.io',
    'scope': 'create update',
    'created_at': datetime(...),
    'expires_at': datetime(...) or None
}
```

### AuthState Row

```python
{
    'state': 'random123...',
    'created_at': datetime(...),
    'expires_at': datetime(...)
}
```

## Verification Checklist

Before marking Phase 1.2 complete:

- [ ] All 4 models implemented
- [ ] All models are frozen dataclasses
- [ ] All models have from_row() class method
- [ ] All models have to_dict() method
- [ ] Note model lazy-loads content
- [ ] Note model renders HTML with caching
- [ ] Session model validates expiry
- [ ] Token model validates scopes
- [ ] All properties have type hints
- [ ] All methods have docstrings with examples
- [ ] Test file created with >90% coverage
- [ ] All tests pass
- [ ] Code formatted with Black
- [ ] Code passes flake8
- [ ] No security issues
- [ ] Integration with utils.py works

## Usage Quick Examples

### Note Model

```python
# Create from database
row = db.execute("SELECT * FROM notes WHERE slug = ?", (slug,)).fetchone()
note = Note.from_row(row, data_dir=Path("data"))

# Access metadata (fast)
print(note.slug, note.published)

# Lazy load content (slow first time, cached after)
content = note.content

# Render HTML (slow first time, cached after)
html = note.html

# Extract metadata
title = note.title
permalink = note.permalink

# Serialize for JSON/templates
data = note.to_dict(include_content=True)
```

### Session Model

```python
# Create from database
row = db.execute("SELECT * FROM sessions WHERE session_token = ?", (token,)).fetchone()
session = Session.from_row(row)

# Validate
if session.is_valid():
    # Update last used
    updated = session.with_updated_last_used()
    # Save to database
    db.execute("UPDATE sessions SET last_used_at = ? WHERE id = ?",
               (updated.last_used_at, updated.id))
```

### Token Model

```python
# Create from database
row = db.execute("SELECT * FROM tokens WHERE token = ?", (token,)).fetchone()
token_obj = Token.from_row(row)

# Validate with required scope
if token_obj.is_valid(required_scope='create'):
    # Allow request
    pass
```

### AuthState Model

```python
# Create from database
row = db.execute("SELECT * FROM auth_state WHERE state = ?", (state,)).fetchone()
auth_state = AuthState.from_row(row)

# Validate
if auth_state.is_valid():
    # Delete (single-use)
    db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
```

## Next Steps After Implementation

Once `starpunk/models.py` is complete:

1. Move to Phase 2.1: Notes Management (`starpunk/notes.py`)
2. Notes module will use Note model extensively
3. Integration tests will verify models work with database

## References

- Full design: `/home/phil/Projects/starpunk/docs/design/phase-1.2-data-models.md`
- Database schema: `/home/phil/Projects/starpunk/starpunk/database.py`
- Utilities: `/home/phil/Projects/starpunk/starpunk/utils.py`
- Python dataclasses: https://docs.python.org/3/library/dataclasses.html

## Quick Command Reference

```bash
# Run tests
pytest tests/test_models.py -v

# Run tests with coverage
pytest tests/test_models.py --cov=starpunk.models --cov-report=term-missing

# Format code
black starpunk/models.py tests/test_models.py

# Lint code
flake8 starpunk/models.py tests/test_models.py

# Type check (optional)
mypy starpunk/models.py
```

## Estimated Time Breakdown

- Module docstring and imports: 10 minutes
- Constants: 5 minutes
- Note model: 80 minutes
  - Basic structure: 15 minutes
  - from_row: 10 minutes
  - Lazy loading properties: 20 minutes
  - HTML rendering: 15 minutes
  - Metadata extraction: 15 minutes
  - Other methods: 5 minutes
- Session model: 30 minutes
- Token model: 30 minutes
- AuthState model: 20 minutes
- Tests (all models): 90-120 minutes
- Documentation review: 15 minutes

**Total**: 3-4 hours

## Implementation Tips

### Frozen Dataclass Caching

Since dataclasses with `frozen=True` are immutable, use this pattern for caching:

```python
@property
def content(self) -> str:
    if self._cached_content is None:
        content = read_note_file(self._data_dir / self.file_path)
        # Use object.__setattr__ to bypass frozen restriction
        object.__setattr__(self, '_cached_content', content)
    return self._cached_content
```

### Datetime Handling

Database may return strings or datetime objects:

```python
@classmethod
def from_row(cls, row: dict) -> 'Note':
    created_at = row['created_at']
    if isinstance(created_at, str):
        created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
    # ... rest of method
```

### Testing with tmp_path

Use pytest's tmp_path fixture for file operations:

```python
def test_content_loading(tmp_path):
    # Create test file
    note_file = tmp_path / 'notes' / '2024' / '11' / 'test.md'
    note_file.parent.mkdir(parents=True)
    note_file.write_text('# Test')

    # Create note with tmp_path as data_dir
    note = Note(
        id=1,
        slug='test',
        file_path='notes/2024/11/test.md',
        published=True,
        created_at=datetime.utcnow(),
        updated_at=datetime.utcnow(),
        _data_dir=tmp_path
    )

    assert '# Test' in note.content
```

### Markdown Extension Configuration

```python
import markdown

html = markdown.markdown(
    content,
    extensions=['extra', 'codehilite', 'nl2br'],
    extension_configs={
        'codehilite': {'css_class': 'highlight'}
    }
)
```
