From 35686bdb663f400d67fca1ce979ae07c58bcec26 Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Mon, 9 Mar 2026 16:10:34 +0100 Subject: [PATCH] phase1: bootstrap containerized django stack and project scaffold --- .dockerignore | 16 +++++ .env.example | 29 ++++++++ .gitignore | 28 ++++++++ CONTRIBUTING.md | 46 ++++++++++++ Dockerfile | 22 ++++++ README.md | 102 +++++++++++++++++++++++++- apps/__init__.py | 0 apps/core/__init__.py | 0 apps/core/apps.py | 6 ++ apps/core/migrations/__init__.py | 0 apps/core/urls.py | 8 +++ apps/core/views.py | 10 +++ config/__init__.py | 3 + config/asgi.py | 6 ++ config/celery.py | 8 +++ config/settings/__init__.py | 0 config/settings/base.py | 99 +++++++++++++++++++++++++ config/settings/development.py | 3 + config/settings/local.py | 1 + config/settings/production.py | 13 ++++ config/urls.py | 7 ++ config/wsgi.py | 6 ++ docker-compose.yml | 119 +++++++++++++++++++++++++++++++ entrypoint.sh | 21 ++++++ manage.py | 19 +++++ nginx/nginx.conf | 51 +++++++++++++ pytest.ini | 3 + requirements/base.txt | 6 ++ requirements/dev.txt | 3 + templates/home.html | 12 ++++ tests/__init__.py | 0 tests/test_health.py | 9 +++ 32 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 apps/__init__.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/migrations/__init__.py create mode 100644 apps/core/urls.py create mode 100644 apps/core/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 manage.py create mode 100644 nginx/nginx.conf create mode 100644 pytest.ini create mode 100644 requirements/base.txt create mode 100644 requirements/dev.txt create mode 100644 templates/home.html create mode 100644 tests/__init__.py create mode 100644 tests/test_health.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a29c92c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +__pycache__ +*.pyc +*.pyo +*.pyd +*.db +.pytest_cache +.mypy_cache +.ruff_cache +.env +.env.* +venv +.venv +media +staticfiles diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0091c7a --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Django +DJANGO_SETTINGS_MODULE=config.settings.development +DJANGO_SECRET_KEY=change-me-in-production +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 +DJANGO_TIME_ZONE=UTC +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_EMAIL=admin@example.com +DJANGO_SUPERUSER_PASSWORD=adminpass + +# Database (PostgreSQL only) +POSTGRES_DB=hoopscout +POSTGRES_USER=hoopscout +POSTGRES_PASSWORD=hoopscout +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 + +# Redis / Celery +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# Runtime behavior +AUTO_APPLY_MIGRATIONS=1 +AUTO_COLLECTSTATIC=1 +GUNICORN_WORKERS=3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..323391f --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Django +*.log +local_settings.py +media/ +staticfiles/ + +# Virtual environments +.venv/ +venv/ + +# Env files +.env +.env.* +!.env.example + +# IDE +.vscode/ +.idea/ +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..93ac88f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to HoopScout + +## Branching Model (GitFlow Required) + +- `main`: production-ready releases only +- `develop`: integration branch for completed features +- `feature/*`: feature development branches from `develop` +- `release/*`: release hardening branches from `develop` +- `hotfix/*`: urgent production fixes from `main` + +## Workflow + +1. Create branch from the correct base: + - `feature/*` from `develop` + - `release/*` from `develop` + - `hotfix/*` from `main` +2. Keep branch scope small and focused. +3. Open PR into the proper target branch. +4. Require passing CI checks and at least one review. +5. Squash or rebase merge according to repository policy. + +## Local Development + +1. `cp .env.example .env` +2. `docker compose up --build` +3. `docker compose exec web python manage.py migrate` +4. `docker compose exec web python manage.py createsuperuser` + +## Testing + +```bash +docker compose exec web pytest +``` + +## Commit Guidance + +- Use clear commit messages with intent and scope. +- Avoid mixing refactors with feature behavior changes. +- Include migration files when model changes are introduced. + +## Definition of Done (MVP) + +- Feature works in Dockerized environment. +- Tests added/updated for behavior change. +- Documentation updated when commands, config, or workflows change. +- No secrets committed. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fc473e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev postgresql-client curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements/base.txt /tmp/requirements/base.txt +RUN pip install --upgrade pip && pip install -r /tmp/requirements/base.txt + +COPY . /app + +RUN chmod +x /app/entrypoint.sh +RUN mkdir -p /app/staticfiles /app/media /app/runtime + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/README.md b/README.md index f082eef..e1feaf5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ -# hoopscout +# HoopScout +Production-minded basketball scouting and player search platform built with Django, HTMX, PostgreSQL, Redis, Celery, and nginx. + +## Stack + +- Python 3.12+ +- Django + Django Templates + HTMX (no SPA) +- PostgreSQL +- Redis +- Celery + Celery Beat +- nginx +- Docker / Docker Compose + +## Repository Structure + +```text +. +├── apps/ +├── config/ +├── nginx/ +├── requirements/ +├── templates/ +├── tests/ +├── docker-compose.yml +├── Dockerfile +└── entrypoint.sh +``` + +## Setup + +1. Copy environment file: + +```bash +cp .env.example .env +``` + +2. Build and start services: + +```bash +docker compose up --build +``` + +3. Apply migrations (if not using auto migrations): + +```bash +docker compose exec web python manage.py migrate +``` + +4. Create superuser: + +```bash +docker compose exec web python manage.py createsuperuser +``` + +5. Access app: + +- Application: http://localhost +- Admin: http://localhost/admin/ +- Health endpoint: http://localhost/health/ + +## Development Commands + +Run tests: + +```bash +docker compose exec web pytest +``` + +Run Django shell: + +```bash +docker compose exec web python manage.py shell +``` + +Collect static files: + +```bash +docker compose exec web python manage.py collectstatic --noinput +``` + +## Migrations + +Create migration: + +```bash +docker compose exec web python manage.py makemigrations +``` + +Apply migration: + +```bash +docker compose exec web python manage.py migrate +``` + +## Ingestion / Sync (Phase Placeholder) + +Data provider ingestion flow will be added in a later phase with adapter-based provider isolation. + +## GitFlow + +See [CONTRIBUTING.md](CONTRIBUTING.md) for branch model and PR workflow. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..ab0051e --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..8876b49 --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import health, home + +urlpatterns = [ + path("", home, name="home"), + path("health/", health, name="health"), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..722faf4 --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,10 @@ +from django.http import JsonResponse +from django.shortcuts import render + + +def home(request): + return render(request, "home.html") + + +def health(request): + return JsonResponse({"status": "ok"}) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..53f4ccb --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..bc727b9 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,6 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..0f0d798 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,8 @@ +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +app = Celery("hoopscout") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..40838c7 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,99 @@ +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + + +def env_bool(key: str, default: bool = False) -> bool: + value = os.getenv(key) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def env_list(key: str, default: str = "") -> list[str]: + value = os.getenv(key, default) + return [item.strip() for item in value.split(",") if item.strip()] + + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure-development-secret") +DEBUG = env_bool("DJANGO_DEBUG", False) +ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") +CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "apps.core", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB", "hoopscout"), + "USER": os.getenv("POSTGRES_USER", "hoopscout"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", "hoopscout"), + "HOST": os.getenv("POSTGRES_HOST", "postgres"), + "PORT": os.getenv("POSTGRES_PORT", "5432"), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC") +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://redis:6379/0") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = TIME_ZONE diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..8eaa66f --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,3 @@ +from .base import * # noqa: F403,F401 + +DEBUG = True diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..22fd16c --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1 @@ +from .development import * # noqa: F403,F401 diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..b6d4d00 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,13 @@ +from .base import * # noqa: F403,F401 +import os + +DEBUG = False + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_HSTS_SECONDS = int(os.getenv("DJANGO_SECURE_HSTS_SECONDS", "31536000")) +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..32eaaec --- /dev/null +++ b/config/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("apps.core.urls")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..a01fb22 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d722bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,119 @@ +services: + nginx: + image: nginx:1.27-alpine + depends_on: + web: + condition: service_healthy + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - static_data:/var/www/static:ro + - media_data:/var/www/media:ro + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health/ || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + restart: unless-stopped + + web: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} + volumes: + - .:/app + - static_data:/app/staticfiles + - media_data:/app/media + - runtime_data:/app/runtime + expose: + - "8000" + healthcheck: + test: ["CMD-SHELL", "curl -f http://127.0.0.1:8000/health/ || exit 1"] + interval: 15s + timeout: 5s + retries: 8 + restart: unless-stopped + + celery_worker: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: celery -A config worker -l info + volumes: + - .:/app + - runtime_data:/app/runtime + healthcheck: + test: ["CMD-SHELL", "celery -A config inspect ping -d celery@$$HOSTNAME | grep -q pong || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + restart: unless-stopped + + celery_beat: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: celery -A config beat -l info --schedule=/app/runtime/celerybeat-schedule + volumes: + - .:/app + - runtime_data:/app/runtime + healthcheck: + test: ["CMD-SHELL", "test -f /app/runtime/celerybeat-schedule || exit 1"] + interval: 30s + timeout: 5s + retries: 10 + restart: unless-stopped + + postgres: + image: postgres:16-alpine + env_file: + - .env + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: redis-server --save 60 1 --loglevel warning + volumes: + - runtime_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: + static_data: + media_data: + runtime_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..c817fc7 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -e + +echo "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." +until pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -U "${POSTGRES_USER}"; do + sleep 1 +done + +echo "PostgreSQL is available." + +if [ "${AUTO_APPLY_MIGRATIONS:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then + echo "Applying database migrations..." + python manage.py migrate --noinput +fi + +if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then + echo "Collecting static files..." + python manage.py collectstatic --noinput +fi + +exec "$@" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..157ecbd --- /dev/null +++ b/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and available on your " + "PYTHONPATH environment variable?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..a58e526 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,51 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 20m; + + upstream django_upstream { + server web:8000; + } + + server { + listen 80; + server_name _; + + location /static/ { + alias /var/www/static/; + expires 30d; + access_log off; + } + + location /media/ { + alias /var/www/media/; + expires 30d; + access_log off; + } + + location /health/ { + proxy_pass http://django_upstream/health/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://django_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..79c1877 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.development +python_files = tests.py test_*.py *_tests.py diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..1070d58 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,6 @@ +Django>=5.1,<6.0 +psycopg[binary]>=3.2,<4.0 +gunicorn>=22.0,<23.0 +celery[redis]>=5.4,<6.0 +redis>=5.2,<6.0 +python-dotenv>=1.0,<2.0 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..2257aa8 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,3 @@ +-r base.txt +pytest>=8.3,<9.0 +pytest-django>=4.9,<5.0 diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..e620bd7 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,12 @@ + + + + + + HoopScout + + +

HoopScout

+

Phase 1 bootstrap is running.

+ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..2ca4bb2 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,9 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_health_endpoint(client): + response = client.get(reverse("health")) + assert response.status_code == 200 + assert response.json()["status"] == "ok"