Compare commits

..

No commits in common. "27c65ce40da61e5af04398c3b4a669206ea36888" and "81edf81d13089b3514ea148ba8215fad13a63d80" have entirely different histories.

20 changed files with 64 additions and 252 deletions

View File

@ -1,12 +1,9 @@
import sys
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
SECRET_KEY: str = "your-secret-key-change-in-production"
ENVIRONMENT: str = "development"
COOKIE_SECURE: bool = False
model_config = SettingsConfigDict(
env_file=".env",
@ -16,17 +13,3 @@ class Settings(BaseSettings):
settings = Settings()
if settings.SECRET_KEY == "your-secret-key-change-in-production":
if settings.ENVIRONMENT != "development":
print(
"FATAL: Default SECRET_KEY detected in non-development environment. "
"Set a unique SECRET_KEY in .env immediately.",
file=sys.stderr,
)
sys.exit(1)
else:
print(
"WARNING: Using default SECRET_KEY. Set SECRET_KEY in .env for production.",
file=sys.stderr,
)

View File

@ -5,7 +5,7 @@ from app.config import settings
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
echo=True,
future=True
)
@ -27,6 +27,7 @@ async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

View File

@ -2,13 +2,17 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import engine
from app.database import engine, Base
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown: Clean up resources
await engine.dispose()
@ -22,7 +26,7 @@ app = FastAPI(
# CORS configuration for development
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@ -1,9 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie, Request
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from collections import defaultdict
import time
import bcrypt
from itsdangerous import TimestampSigner, BadSignature
@ -17,35 +15,6 @@ router = APIRouter()
# Initialize signer for session management
signer = TimestampSigner(app_settings.SECRET_KEY)
# Brute-force protection: track failed login attempts per IP
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_ATTEMPTS = 5
_WINDOW_SECONDS = 300 # 5-minute lockout window
# Server-side session revocation (in-memory, sufficient for single-user app)
_revoked_sessions: set[str] = set()
def _check_rate_limit(ip: str) -> None:
"""Raise 429 if IP has exceeded failed login attempts."""
now = time.time()
attempts = _failed_attempts[ip]
# Prune old entries outside the window
_failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS]
# Remove the key entirely if no recent attempts remain
if not _failed_attempts[ip]:
del _failed_attempts[ip]
elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_failed_attempt(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
_failed_attempts[ip].append(time.time())
def hash_pin(pin: str) -> str:
"""Hash a PIN using bcrypt."""
@ -71,18 +40,6 @@ def verify_session_token(token: str) -> Optional[int]:
return None
def _set_session_cookie(response: Response, token: str) -> None:
"""Set the session cookie with secure defaults."""
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=86400 * 30, # 30 days
samesite="lax",
)
async def get_current_session(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
@ -91,10 +48,6 @@ async def get_current_session(
if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check if session has been revoked
if session_cookie in _revoked_sessions:
raise HTTPException(status_code=401, detail="Session has been revoked")
user_id = verify_session_token(session_cookie)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired session")
@ -115,7 +68,7 @@ async def setup_pin(
db: AsyncSession = Depends(get_db)
):
"""Create initial PIN. Only works if no settings exist."""
result = await db.execute(select(Settings).with_for_update())
result = await db.execute(select(Settings))
existing = result.scalar_one_or_none()
if existing:
@ -129,7 +82,13 @@ async def setup_pin(
# Create session
token = create_session_token(new_settings.id)
_set_session_cookie(response, token)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Setup completed successfully", "authenticated": True}
@ -137,14 +96,10 @@ async def setup_pin(
@router.post("/login")
async def login(
data: SettingsCreate,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Verify PIN and create session."""
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
@ -152,33 +107,25 @@ async def login(
raise HTTPException(status_code=400, detail="Setup required")
if not verify_pin(data.pin, settings_obj.pin_hash):
_record_failed_attempt(client_ip)
raise HTTPException(status_code=401, detail="Invalid PIN")
# Clear failed attempts on successful login
_failed_attempts.pop(client_ip, None)
# Create session
token = create_session_token(settings_obj.id)
_set_session_cookie(response, token)
response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Login successful", "authenticated": True}
@router.post("/logout")
async def logout(
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session")
):
"""Clear session cookie and invalidate server-side session."""
if session_cookie:
_revoked_sessions.add(session_cookie)
response.delete_cookie(
key="session",
httponly=True,
secure=app_settings.COOKIE_SECURE,
samesite="lax"
)
async def logout(response: Response):
"""Clear session cookie."""
response.delete_cookie(key="session")
return {"message": "Logout successful"}
@ -195,11 +142,8 @@ async def auth_status(
authenticated = False
if not setup_required and session_cookie:
if session_cookie in _revoked_sessions:
authenticated = False
else:
user_id = verify_session_token(session_cookie)
authenticated = user_id is not None
user_id = verify_session_token(session_cookie)
authenticated = user_id is not None
return {
"authenticated": authenticated,

View File

@ -132,11 +132,9 @@ async def get_upcoming(
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
# Get upcoming events (from today onward)
today_start = datetime.combine(today, datetime.min.time())
# Get upcoming events
events_query = select(CalendarEvent).where(
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
CalendarEvent.start_datetime <= cutoff_datetime
)
events_result = await db.execute(events_query)
events = events_result.scalars().all()

View File

@ -24,10 +24,10 @@ async def get_events(
query = select(CalendarEvent)
if start:
query = query.where(CalendarEvent.end_datetime >= start)
query = query.where(CalendarEvent.start_datetime >= start)
if end:
query = query.where(CalendarEvent.start_datetime <= end)
query = query.where(CalendarEvent.end_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc())

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from datetime import datetime
from datetime import datetime, timezone
from app.database import get_db
from app.models.todo import Todo
@ -95,7 +95,7 @@ async def update_todo(
# Handle completion timestamp
if "completed" in update_data:
if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now()
update_data["completed_at"] = datetime.now(timezone.utc)
elif not update_data["completed"]:
update_data["completed_at"] = None
@ -141,7 +141,7 @@ async def toggle_todo(
raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed
todo.completed_at = datetime.now() if todo.completed else None
todo.completed_at = datetime.now(timezone.utc) if todo.completed else None
await db.commit()
await db.refresh(todo)

View File

@ -1,15 +1,13 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, List, Literal
from typing import Optional, List
from app.schemas.project_task import ProjectTaskResponse
ProjectStatus = Literal["not_started", "in_progress", "completed"]
class ProjectCreate(BaseModel):
name: str
description: Optional[str] = None
status: ProjectStatus = "not_started"
status: str = "not_started"
color: Optional[str] = None
due_date: Optional[date] = None
@ -17,7 +15,7 @@ class ProjectCreate(BaseModel):
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[ProjectStatus] = None
status: Optional[str] = None
color: Optional[str] = None
due_date: Optional[date] = None

View File

@ -1,16 +1,13 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, List, Literal
TaskStatus = Literal["pending", "in_progress", "completed"]
TaskPriority = Literal["low", "medium", "high"]
from typing import Optional, List
class ProjectTaskCreate(BaseModel):
title: str
description: Optional[str] = None
status: TaskStatus = "pending"
priority: TaskPriority = "medium"
status: str = "pending"
priority: str = "medium"
due_date: Optional[date] = None
person_id: Optional[int] = None
sort_order: int = 0
@ -20,8 +17,8 @@ class ProjectTaskCreate(BaseModel):
class ProjectTaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[TaskStatus] = None
priority: Optional[TaskPriority] = None
status: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[date] = None
person_id: Optional[int] = None
sort_order: Optional[int] = None

View File

@ -1,29 +1,13 @@
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Literal, Optional
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
def _validate_pin_length(v: str, label: str = "PIN") -> str:
if len(v) < 4:
raise ValueError(f'{label} must be at least 4 characters')
if len(v) > 72:
raise ValueError(f'{label} must be at most 72 characters')
return v
class SettingsCreate(BaseModel):
pin: str
@field_validator('pin')
@classmethod
def pin_length(cls, v: str) -> str:
return _validate_pin_length(v)
class SettingsUpdate(BaseModel):
accent_color: Optional[AccentColor] = None
accent_color: str | None = None
upcoming_days: int | None = None
@ -40,8 +24,3 @@ class SettingsResponse(BaseModel):
class ChangePinRequest(BaseModel):
old_pin: str
new_pin: str
@field_validator('new_pin')
@classmethod
def new_pin_length(cls, v: str) -> str:
return _validate_pin_length(v, "New PIN")

View File

@ -1,14 +1,12 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, Literal
TodoPriority = Literal["low", "medium", "high"]
from typing import Optional
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
priority: TodoPriority = "medium"
priority: str = "medium"
due_date: Optional[date] = None
category: Optional[str] = None
recurrence_rule: Optional[str] = None
@ -18,7 +16,7 @@ class TodoCreate(BaseModel):
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[TodoPriority] = None
priority: Optional[str] = None
due_date: Optional[date] = None
completed: Optional[bool] = None
category: Optional[str] = None

View File

@ -32,15 +32,10 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@ -1,9 +1,8 @@
import { useState, FormEvent } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Lock } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@ -15,11 +14,6 @@ export default function LockScreen() {
const [pin, setPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
// Redirect authenticated users to dashboard
if (authStatus?.authenticated) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -36,15 +30,15 @@ export default function LockScreen() {
await setup(pin);
toast.success('PIN created successfully');
navigate('/dashboard');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to create PIN'));
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to create PIN');
}
} else {
try {
await login(pin);
navigate('/dashboard');
} catch (error) {
toast.error(getErrorMessage(error, 'Invalid PIN'));
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Invalid PIN');
setPin('');
}
}

View File

@ -17,8 +17,7 @@ export default function DashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: async () => {
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const today = new Date().toISOString().split('T')[0];
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
return data;
},

View File

@ -155,10 +155,7 @@ export default function ProjectDetail() {
</Button>
<Button
variant="destructive"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
onClick={() => deleteProjectMutation.mutate()}
disabled={deleteProjectMutation.isPending}
>
Delete Project
@ -263,10 +260,7 @@ export default function ProjectDetail() {
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(task.id);
}}
onClick={() => deleteTaskMutation.mutate(task.id)}
disabled={deleteTaskMutation.isPending}
title="Delete task"
>
@ -327,10 +321,7 @@ export default function ProjectDetail() {
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!window.confirm('Delete this subtask?')) return;
deleteTaskMutation.mutate(subtask.id);
}}
onClick={() => deleteTaskMutation.mutate(subtask.id)}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>

View File

@ -93,7 +93,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as Project['status'] })}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>

View File

@ -109,7 +109,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
@ -122,7 +122,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>

View File

@ -27,7 +27,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
const [formData, setFormData] = useState({
title: reminder?.title || '',
description: reminder?.description || '',
remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '',
remind_at: reminder?.remind_at || '',
recurrence_rule: reminder?.recurrence_rule || '',
});

View File

@ -5,7 +5,6 @@ const api = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
api.interceptors.response.use(

View File

@ -64,15 +64,6 @@ Personal life administration web app with dark theme, accent color customization
- [x] Frontend: `CalendarWidget` (today's events with color indicators)
- [x] Frontend: Active reminders section in dashboard
### Phase 6b: Project Subtasks
- [x] Backend: Self-referencing `parent_task_id` FK on `project_tasks` with CASCADE delete
- [x] Backend: Alembic migration `002_add_subtasks.py`
- [x] Backend: Schema updates — `parent_task_id` in create, nested `subtasks` in response, `model_rebuild()`
- [x] Backend: Chained `selectinload` for two-level subtask loading, parent validation on create
- [x] Frontend: `ProjectTask` type updated with `parent_task_id` and `subtasks`
- [x] Frontend: `TaskForm` accepts `parentTaskId` prop, context-aware dialog title
- [x] Frontend: `ProjectDetail` — expand/collapse chevrons, subtask progress bars, indented subtask cards
### Phase 7: Settings & Polish
- [x] Backend: Settings router (get/update settings, change PIN)
- [x] Frontend: `SettingsPage` (accent color picker, upcoming range, PIN change)
@ -128,64 +119,6 @@ These were found during first Docker build and integration testing:
---
## Code Review Findings — Round 1 (Senior Review)
### Critical:
- [x] C1: CORS `allow_origins=["*"]` with `allow_credentials=True` — already restricted to `["http://localhost:5173"]` (`main.py`)
- [x] C2: `datetime.now(timezone.utc)` in naive column — changed to `datetime.now()` (`todos.py`)
- [x] C3: Session cookie missing `secure` flag — added `secure=True` + `_set_session_cookie` helper (`auth.py`)
- [x] C4: No PIN length validation on backend — added `field_validator` for min 4 chars (`schemas/settings.py`)
### High:
- [x] H1: No brute-force protection on login — added in-memory rate limiting (5 attempts / 5 min) (`auth.py`)
- [x] H2: `echo=True` on SQLAlchemy engine — set to `False` (`database.py`)
- [x] H3: Double commit pattern — removed auto-commit from `get_db`, routers handle commits (`database.py`)
- [ ] H4: `Person.relationship` column shadows SQLAlchemy name — deferred (requires migration + schema changes across stack)
- [x] H5: Upcoming events missing lower bound filter — added `>= today_start` (`dashboard.py`)
- [x] H6: `ReminderForm.tsx` doesn't slice `remind_at` — added `.slice(0, 16)` for datetime-local input
### Medium:
- [x] M1: Default `SECRET_KEY` is predictable — added stderr warning on startup (`config.py`)
- [x] M3: `create_all` in lifespan conflicts with Alembic — removed (`main.py`)
- [x] M6: No confirmation dialog before destructive actions — added `window.confirm()` on all delete buttons
- [x] M7: Authenticated users can still navigate to `/login` — added `Navigate` redirect in `LockScreen.tsx`
- [x] L1: Error handling in LockScreen used `error: any` — replaced with `getErrorMessage` helper
## Code Review Findings — Round 2 (Senior Review)
### Critical:
- [x] C1: Default SECRET_KEY only warns, doesn't block production — added env-aware fail-fast (`config.py`)
- [x] C2: `secure=True` cookie breaks HTTP development — made configurable via `COOKIE_SECURE` setting (`auth.py`, `config.py`)
- [x] C3: No enum validation on status/priority fields — added `Literal` types (`schemas/project_task.py`, `todo.py`, `project.py`)
- [x] C4: Race condition in PIN setup (TOCTOU) — added `select().with_for_update()` (`auth.py`)
### High:
- [x] H1: Rate limiter memory leak — added stale key cleanup, `del` empty entries (`auth.py`)
- [ ] H2: Dashboard runs 7 sequential DB queries — deferred (asyncpg single-session limitation)
- [ ] H3: Subtask eager loading fragile at 2 levels — accepted (business logic enforces single nesting)
- [x] H4: No `withCredentials` on Axios for Vite dev — added to `api.ts`
- [x] H5: Logout doesn't invalidate session server-side — added in-memory `_revoked_sessions` set (`auth.py`)
### Medium:
- [ ] M1: TodosPage fetches all then filters client-side — deferred (acceptable for personal app scale)
- [x] M2: Dashboard uses `.toISOString()` violating CLAUDE.md rules — replaced with local date formatter (`DashboardPage.tsx`)
- [x] M3: No CSP header in nginx — added CSP + Referrer-Policy, removed deprecated X-XSS-Protection (`nginx.conf`)
- [x] M4: Event date filtering misses range-spanning events — fixed range overlap logic (`events.py`)
- [x] M5: `accent_color` accepts arbitrary strings — added `Literal` validation for allowed colors (`schemas/settings.py`)
- [x] M6: Logout `delete_cookie` doesn't match `set_cookie` attributes — matched all cookie params (`auth.py`)
- [x] M7: bcrypt silently truncates PIN at 72 bytes — added max 72 char validation (`schemas/settings.py`)
### Low:
- [x] L1: `as any` type casts in frontend forms — replaced with proper `Type['field']` casts (`TaskForm.tsx`, `ProjectForm.tsx`)
- [x] L2: Unused imports in `events.py` — false positive, all imports are used
- [ ] L3: Upcoming endpoint mixes date/datetime string sorting — deferred (works correctly for ISO format)
- [ ] L4: Backend port 8000 exposed directly, bypassing nginx — deferred (useful for dev)
- [ ] L5: `parseInt(id!)` without NaN validation — deferred (low risk, route-level protection)
- [x] L6: `X-XSS-Protection` header is deprecated — removed, replaced with CSP (`nginx.conf`)
- [x] L7: Missing `Referrer-Policy` header — added `strict-origin-when-cross-origin` (`nginx.conf`)
---
## Outstanding Items (Resume Here If Halted)
### Critical (blocks deployment):
@ -219,8 +152,7 @@ backend/
│ ├── env.py
│ ├── script.py.mako
│ └── versions/
│ ├── 001_initial_migration.py
│ └── 002_add_subtasks.py
│ └── 001_initial_migration.py
└── app/
├── main.py
├── config.py