diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec9ee05 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76ef53a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1afb733 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..22b171c --- /dev/null +++ b/app/logging_config.py @@ -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, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d308654 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/health.py b/app/routes/health.py new file mode 100644 index 0000000..d110461 --- /dev/null +++ b/app/routes/health.py @@ -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", + } diff --git a/app/routes/pages.py b/app/routes/pages.py new file mode 100644 index 0000000..a46e6fa --- /dev/null +++ b/app/routes/pages.py @@ -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") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/static/css/sneakyswole.css b/app/static/css/sneakyswole.css new file mode 100644 index 0000000..d71c2f6 --- /dev/null +++ b/app/static/css/sneakyswole.css @@ -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; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..0877b3c --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,47 @@ + + + + + + {% block title %}SneakySwole{% endblock %} + + + + + + + + + + + {% block head_extra %}{% endblock %} + + +
+ +
+ +
+ {% block flash %}{% endblock %} + {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/pages/home.html b/app/templates/pages/home.html new file mode 100644 index 0000000..59c5075 --- /dev/null +++ b/app/templates/pages/home.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}SneakySwole — Home{% endblock %} + +{% block content %} +
+

SneakySwole

+

Your open-source workout tracker

+
+ +

Welcome to SneakySwole. Get started by logging in.

+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..deb0d08 --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bbbf0de --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c83a8d1 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a104f02 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..8c751ee --- /dev/null +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7823392 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..add611d --- /dev/null +++ b/tests/test_health.py @@ -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 diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..fef8fb0 --- /dev/null +++ b/tests/test_logging.py @@ -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") diff --git a/tests/test_pages.py b/tests/test_pages.py new file mode 100644 index 0000000..58538bf --- /dev/null +++ b/tests/test_pages.py @@ -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