phase1: bootstrap containerized django stack and project scaffold

This commit is contained in:
Alfredo Di Stasio
2026-03-09 16:10:34 +01:00
parent 8cd65349c8
commit 35686bdb66
32 changed files with 655 additions and 1 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
*.db
.pytest_cache
.mypy_cache
.ruff_cache
.env
.env.*
venv
.venv
media
staticfiles

29
.env.example Normal file
View File

@ -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

28
.gitignore vendored Normal file
View File

@ -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

46
CONTRIBUTING.md Normal file
View File

@ -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.

22
Dockerfile Normal file
View File

@ -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"]

102
README.md
View File

@ -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.

0
apps/__init__.py Normal file
View File

0
apps/core/__init__.py Normal file
View File

6
apps/core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"

View File

8
apps/core/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import path
from .views import health, home
urlpatterns = [
path("", home, name="home"),
path("health/", health, name="health"),
]

10
apps/core/views.py Normal file
View File

@ -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"})

3
config/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

6
config/asgi.py Normal file
View File

@ -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()

8
config/celery.py Normal file
View File

@ -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()

View File

99
config/settings/base.py Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
from .base import * # noqa: F403,F401
DEBUG = True

1
config/settings/local.py Normal file
View File

@ -0,0 +1 @@
from .development import * # noqa: F403,F401

View File

@ -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

7
config/urls.py Normal file
View File

@ -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")),
]

6
config/wsgi.py Normal file
View File

@ -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()

119
docker-compose.yml Normal file
View File

@ -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:

21
entrypoint.sh Executable file
View File

@ -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 "$@"

19
manage.py Normal file
View File

@ -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()

51
nginx/nginx.conf Normal file
View File

@ -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;
}
}
}

3
pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = tests.py test_*.py *_tests.py

6
requirements/base.txt Normal file
View File

@ -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

3
requirements/dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r base.txt
pytest>=8.3,<9.0
pytest-django>=4.9,<5.0

12
templates/home.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HoopScout</title>
</head>
<body>
<h1>HoopScout</h1>
<p>Phase 1 bootstrap is running.</p>
</body>
</html>

0
tests/__init__.py Normal file
View File

9
tests/test_health.py Normal file
View File

@ -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"