add models, migrations, and CRUD

Schema mirrors the webapp tasks module's ScheduledEvent + EventCompletion
shape:

- tasks.tasks (NIP-52 kind 31922 cache): (pubkey, d_tag) is the
  parameterized-replaceable key. JSON-encoded participants / categories
  / recurrence columns are decoded on read via _parse_task_row so each
  model can keep clean field validators.
- tasks.completions (kind 31925 cache): unique on
  (task_address, pubkey, occurrence). occurrence is NULL for one-shot
  tasks; create_completion deletes any prior claim for the same triple
  so the latest event wins.
- tasks.settings: singleton row with public_listing toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 11:36:13 +02:00
commit 6fbb6d4a42
3 changed files with 521 additions and 3 deletions

157
models.py
View file

@ -1 +1,156 @@
# Pydantic models are filled in by the next commit.
import json
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, validator
TaskStatus = Literal["claimed", "in-progress", "completed", "blocked", "cancelled"]
class Participant(BaseModel):
pubkey: str
type: str | None = None # "required" | "optional" | "organizer"
class Recurrence(BaseModel):
frequency: Literal["daily", "weekly"]
day_of_week: str | None = None # for weekly: monday..sunday
end_date: str | None = None # YYYY-MM-DD, optional cutoff
class CreateTask(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent
title: str
start_date: str # YYYY-MM-DD or ISO datetime
end_date: str | None = None
description: str = ""
location: str | None = None
status: str = "pending"
event_type: str = "task" # 'task' | 'announcement'
participants: list[Participant] = Field(default_factory=list)
categories: list[str] = Field(default_factory=list)
recurrence: Recurrence | None = None
@validator("participants", pre=True)
def parse_participants(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("recurrence", pre=True)
def parse_recurrence(cls, v):
if isinstance(v, str):
return json.loads(v) if v else None
return v
class Task(BaseModel):
id: str
wallet: str
pubkey: str
d_tag: str
title: str
start_date: str
end_date: str | None = None
description: str = ""
location: str | None = None
status: str = "pending"
event_type: str = "task"
participants: list[Participant] = Field(default_factory=list)
categories: list[str] = Field(default_factory=list)
recurrence: Recurrence | None = None
nostr_event_id: str | None = None
nostr_event_created_at: int | None = None
time: datetime
@validator("participants", pre=True)
def parse_participants(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("recurrence", pre=True)
def parse_recurrence(cls, v):
if isinstance(v, str):
return json.loads(v) if v else None
return v
@property
def address(self) -> str:
"""NIP-52 replaceable-event address (31922:pubkey:d-tag)."""
return f"31922:{self.pubkey}:{self.d_tag}"
class PublicTask(BaseModel):
"""Trimmed task payload for the public/anonymous endpoint."""
id: str
pubkey: str
d_tag: str
title: str
start_date: str
end_date: str | None = None
description: str = ""
location: str | None = None
status: str
event_type: str
participants: list[Participant] = Field(default_factory=list)
categories: list[str] = Field(default_factory=list)
recurrence: Recurrence | None = None
@validator("participants", pre=True)
def parse_participants(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("categories", pre=True)
def parse_categories(cls, v):
if isinstance(v, str):
return json.loads(v) if v else []
return v or []
@validator("recurrence", pre=True)
def parse_recurrence(cls, v):
if isinstance(v, str):
return json.loads(v) if v else None
return v
class CreateTaskCompletion(BaseModel):
task_address: str # "31922:pubkey:d_tag"
task_status: TaskStatus = "claimed"
occurrence: str | None = None # YYYY-MM-DD for recurring tasks
completed_at: int | None = None # unix ts; set when task_status == 'completed'
notes: str = ""
class TaskCompletion(BaseModel):
id: str # Nostr event id (or local hash for not-yet-published)
task_address: str
pubkey: str # claimer
occurrence: str | None = None
task_status: TaskStatus = "claimed"
completed_at: int | None = None
notes: str = ""
nostr_created_at: int
time: datetime
class TasksSettings(BaseModel):
"""Extension-level settings singleton."""
public_listing: bool = False # expose /api/v1/tasks/public if enabled