Merge branch 'feat/phase1-scaffold'
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# SneakySwole Environment Configuration
|
||||
# Copy this file to .env and fill in real values.
|
||||
|
||||
# Admin credentials (used to create admin user on first run)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# App settings
|
||||
APP_ENV=development
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
APP_LOG_LEVEL=info
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///data/sneakyswole.db
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# -- Build stage --
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY config/ ./config/
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
53
app/config.py
Normal file
53
app/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Application configuration loader.
|
||||
|
||||
Reads environment variables (with .env file support) and provides
|
||||
a typed, validated Settings object used throughout the application.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
# Load .env file from project root
|
||||
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
load_dotenv(_env_path)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Typed application settings loaded from environment variables.
|
||||
|
||||
Attributes:
|
||||
admin_username: Admin login username (from ADMIN_USERNAME env var).
|
||||
admin_password: Admin login password (from ADMIN_PASSWORD env var).
|
||||
app_env: Runtime environment — 'development' or 'production'.
|
||||
app_host: Host address to bind the server to.
|
||||
app_port: Port number for the server.
|
||||
app_log_level: Minimum log level for structlog output.
|
||||
database_url: SQLite connection string.
|
||||
"""
|
||||
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
app_env: str = "development"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_log_level: str = "info"
|
||||
database_url: str = "sqlite:///data/sneakyswole.db"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return a cached Settings instance (singleton pattern).
|
||||
|
||||
Returns:
|
||||
The application Settings object.
|
||||
"""
|
||||
return Settings()
|
||||
48
app/logging_config.py
Normal file
48
app/logging_config.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Structured logging configuration using structlog.
|
||||
|
||||
Call setup_logging() once at application startup to configure
|
||||
structlog with human-readable (dev) or JSON (prod) output.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def setup_logging(log_level: str = "info") -> None:
|
||||
"""Configure structlog for the application.
|
||||
|
||||
In development, output is colorized and human-readable.
|
||||
In production, output is JSON for structured log aggregation.
|
||||
|
||||
Args:
|
||||
log_level: Minimum log level as a string (e.g., 'info', 'debug').
|
||||
"""
|
||||
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Configure standard library logging (captured by structlog)
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=numeric_level,
|
||||
)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
59
app/main.py
Normal file
59
app/main.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""FastAPI application factory for SneakySwole.
|
||||
|
||||
Creates and configures the FastAPI app with routes, templates,
|
||||
static files, and structured logging.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import get_settings
|
||||
from app.logging_config import setup_logging
|
||||
from app.routes.health import router as health_router
|
||||
from app.routes.pages import router as pages_router
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Template and static file directories
|
||||
_BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATES_DIR = _BASE_DIR / "templates"
|
||||
STATIC_DIR = _BASE_DIR / "static"
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
Returns:
|
||||
A fully configured FastAPI application instance.
|
||||
"""
|
||||
settings = get_settings()
|
||||
setup_logging(log_level=settings.app_log_level)
|
||||
|
||||
app = FastAPI(
|
||||
title="SneakySwole",
|
||||
description="Open-source workout tracking and programming",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Jinja2 templates (available to routes via request.state or dependency)
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
app.state.templates = templates
|
||||
|
||||
# Register route modules
|
||||
app.include_router(health_router)
|
||||
app.include_router(pages_router)
|
||||
|
||||
logger.info("app_started", environment=settings.app_env)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Uvicorn entry point
|
||||
app = create_app()
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
23
app/routes/health.py
Normal file
23
app/routes/health.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Health check route for monitoring and readiness probes.
|
||||
|
||||
Provides a simple GET /health endpoint that returns application
|
||||
status, name, and version.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
"""Return application health status.
|
||||
|
||||
Returns:
|
||||
JSON with app name, version, and status.
|
||||
"""
|
||||
return {
|
||||
"app": "sneakyswole",
|
||||
"version": "0.1.0",
|
||||
"status": "ok",
|
||||
}
|
||||
23
app/routes/pages.py
Normal file
23
app/routes/pages.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Page routes for serving full HTML pages.
|
||||
|
||||
Renders Jinja2 templates for user-facing pages.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home_page(request: Request) -> HTMLResponse:
|
||||
"""Render the home page.
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request.
|
||||
|
||||
Returns:
|
||||
Rendered home page HTML.
|
||||
"""
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(request, "pages/home.html")
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
36
app/static/css/sneakyswole.css
Normal file
36
app/static/css/sneakyswole.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* SneakySwole custom CSS overrides
|
||||
Built on top of Pico CSS dark theme.
|
||||
Keep Pico mostly untouched — only override what's necessary. */
|
||||
|
||||
/* Slightly bolder nav styling */
|
||||
nav h1 {
|
||||
margin-bottom: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Active nav link indicator */
|
||||
nav a[aria-current="page"] {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Spacing for main content area */
|
||||
main.container {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Flash message styling */
|
||||
.flash-success {
|
||||
color: var(--pico-ins-color);
|
||||
border-left: 4px solid var(--pico-ins-color);
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
color: var(--pico-del-color);
|
||||
border-left: 4px solid var(--pico-del-color);
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
47
app/templates/base.html
Normal file
47
app/templates/base.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SneakySwole{% endblock %}</title>
|
||||
|
||||
<!-- Pico CSS (dark theme via data-theme="dark" on <html>) -->
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
|
||||
<!-- Custom overrides -->
|
||||
<link rel="stylesheet" href="/static/css/sneakyswole.css">
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"
|
||||
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="container">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><strong><a href="/">SneakySwole</a></strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
{% block nav_items %}
|
||||
<!-- Phase 3 adds: profile switcher, login/logout -->
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block flash %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="container">
|
||||
<small>SneakySwole — Open-source workout tracking</small>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
12
app/templates/pages/home.html
Normal file
12
app/templates/pages/home.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}SneakySwole — Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>SneakySwole</h1>
|
||||
<p>Your open-source workout tracker</p>
|
||||
</hgroup>
|
||||
|
||||
<p>Welcome to SneakySwole. Get started by logging in.</p>
|
||||
{% endblock %}
|
||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
23
docker-compose.yaml
Normal file
23
docker-compose.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sneakyswole
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- sneakyswole-data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
sneakyswole-data:
|
||||
driver: local
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "sneakyswole"
|
||||
version = "0.1.0"
|
||||
description = "Open-source workout tracking and programming app"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi>=0.115.0,<1.0.0
|
||||
uvicorn[standard]>=0.34.0,<1.0.0
|
||||
jinja2>=3.1.0,<4.0.0
|
||||
structlog>=24.0.0,<25.0.0
|
||||
python-dotenv>=1.0.0,<2.0.0
|
||||
bcrypt>=4.2.0,<5.0.0
|
||||
pyyaml>=6.0.0,<7.0.0
|
||||
sqlmodel>=0.0.22,<1.0.0
|
||||
alembic>=1.14.0,<2.0.0
|
||||
httpx>=0.28.0,<1.0.0
|
||||
pytest>=8.0.0,<9.0.0
|
||||
pytest-asyncio>=0.24.0,<1.0.0
|
||||
pydantic-settings>=2.7.0,<3.0.0
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Shared test fixtures for the SneakySwole test suite."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_test_env() -> None:
|
||||
"""Ensure required env vars are set for all tests."""
|
||||
with patch.dict(os.environ, {
|
||||
"ADMIN_USERNAME": "testadmin",
|
||||
"ADMIN_PASSWORD": "testpass123",
|
||||
"APP_ENV": "development",
|
||||
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
|
||||
}, clear=False):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
"""Create a FastAPI TestClient for integration tests."""
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
12
tests/test_app.py
Normal file
12
tests/test_app.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Tests for the FastAPI application factory."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestCreateApp:
|
||||
"""Tests for the application factory and basic setup."""
|
||||
|
||||
def test_app_starts_without_error(self, client: TestClient) -> None:
|
||||
"""The app should initialize and serve requests."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for the application configuration loader."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Tests for the Settings configuration class."""
|
||||
|
||||
def test_settings_loads_defaults(self) -> None:
|
||||
"""Settings should have sensible defaults for all fields."""
|
||||
env = {
|
||||
"ADMIN_USERNAME": "testadmin",
|
||||
"ADMIN_PASSWORD": "testpass123",
|
||||
}
|
||||
# Remove DATABASE_URL so we test the actual default
|
||||
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
|
||||
env_clean.update(env)
|
||||
with patch.dict(os.environ, env_clean, clear=True):
|
||||
settings = Settings()
|
||||
assert settings.admin_username == "testadmin"
|
||||
assert settings.admin_password == "testpass123"
|
||||
assert settings.app_env == "development"
|
||||
assert settings.app_host == "0.0.0.0"
|
||||
assert settings.app_port == 8000
|
||||
assert settings.database_url == "sqlite:///data/sneakyswole.db"
|
||||
|
||||
def test_settings_requires_admin_username(self) -> None:
|
||||
"""Settings should require ADMIN_USERNAME to be set."""
|
||||
with patch.dict(os.environ, {"ADMIN_PASSWORD": "testpass"}, clear=True):
|
||||
try:
|
||||
Settings()
|
||||
assert False, "Should have raised an error"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_get_settings_returns_singleton(self) -> None:
|
||||
"""get_settings should return the same instance on repeated calls."""
|
||||
with patch.dict(os.environ, {
|
||||
"ADMIN_USERNAME": "admin",
|
||||
"ADMIN_PASSWORD": "pass",
|
||||
}, clear=False):
|
||||
s1 = get_settings()
|
||||
s2 = get_settings()
|
||||
assert s1 is s2
|
||||
30
tests/test_health.py
Normal file
30
tests/test_health.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Tests for the health check endpoint."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""Tests for GET /health."""
|
||||
|
||||
def test_health_returns_200(self, client: TestClient) -> None:
|
||||
"""GET /health should return HTTP 200."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_returns_status_ok(self, client: TestClient) -> None:
|
||||
"""GET /health should return a JSON body with status 'ok'."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
def test_health_includes_app_name(self, client: TestClient) -> None:
|
||||
"""GET /health should include the application name."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert data["app"] == "sneakyswole"
|
||||
|
||||
def test_health_includes_version(self, client: TestClient) -> None:
|
||||
"""GET /health should include the application version."""
|
||||
response = client.get("/health")
|
||||
data = response.json()
|
||||
assert "version" in data
|
||||
22
tests/test_logging.py
Normal file
22
tests/test_logging.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Tests for structured logging configuration."""
|
||||
|
||||
import structlog
|
||||
|
||||
from app.logging_config import setup_logging
|
||||
|
||||
|
||||
class TestLogging:
|
||||
"""Tests for the structlog configuration."""
|
||||
|
||||
def test_setup_logging_configures_structlog(self) -> None:
|
||||
"""setup_logging should configure structlog without errors."""
|
||||
setup_logging(log_level="info")
|
||||
logger = structlog.get_logger("test")
|
||||
assert logger is not None
|
||||
|
||||
def test_logger_can_log_message(self) -> None:
|
||||
"""A configured logger should be able to emit a log message."""
|
||||
setup_logging(log_level="debug")
|
||||
logger = structlog.get_logger("test_module")
|
||||
# Should not raise
|
||||
logger.info("test message", key="value")
|
||||
22
tests/test_pages.py
Normal file
22
tests/test_pages.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Tests for HTML page routes."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestHomePage:
|
||||
"""Tests for GET /."""
|
||||
|
||||
def test_home_returns_200(self, client: TestClient) -> None:
|
||||
"""GET / should return HTTP 200."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_home_contains_app_name(self, client: TestClient) -> None:
|
||||
"""GET / should render HTML containing the app name."""
|
||||
response = client.get("/")
|
||||
assert "SneakySwole" in response.text
|
||||
|
||||
def test_home_has_dark_theme(self, client: TestClient) -> None:
|
||||
"""GET / should use the dark theme."""
|
||||
response = client.get("/")
|
||||
assert 'data-theme="dark"' in response.text
|
||||
Reference in New Issue
Block a user