phase2: add modular apps, auth scaffolding, and base template routing

This commit is contained in:
Alfredo Di Stasio
2026-03-10 10:27:40 +01:00
parent 35686bdb66
commit f47ffe6c15
63 changed files with 594 additions and 24 deletions

View File

@ -5,26 +5,34 @@ Production-minded basketball scouting and player search platform built with Djan
## Stack ## Stack
- Python 3.12+ - Python 3.12+
- Django + Django Templates + HTMX (no SPA) - Django + Django Templates + HTMX (server-rendered)
- PostgreSQL - PostgreSQL
- Redis - Redis
- Celery + Celery Beat - Celery + Celery Beat
- nginx - nginx
- Docker / Docker Compose - Docker / Docker Compose
## Repository Structure ## Project Structure
```text ```text
. .
├── apps/ ├── apps/
│ ├── core/
│ ├── users/
│ ├── players/
│ ├── competitions/
│ ├── teams/
│ ├── stats/
│ ├── scouting/
│ ├── providers/
│ └── ingestion/
├── config/ ├── config/
│ └── settings/
├── nginx/ ├── nginx/
├── requirements/ ├── requirements/
├── static/
├── templates/ ├── templates/
── tests/ ── tests/
├── docker-compose.yml
├── Dockerfile
└── entrypoint.sh
``` ```
## Setup ## Setup
@ -41,7 +49,7 @@ cp .env.example .env
docker compose up --build docker compose up --build
``` ```
3. Apply migrations (if not using auto migrations): 3. Apply migrations (if auto-migrate disabled):
```bash ```bash
docker compose exec web python manage.py migrate docker compose exec web python manage.py migrate
@ -59,6 +67,18 @@ docker compose exec web python manage.py createsuperuser
- Admin: http://localhost/admin/ - Admin: http://localhost/admin/
- Health endpoint: http://localhost/health/ - Health endpoint: http://localhost/health/
## Authentication Routes
- Signup: `/users/signup/`
- Login: `/users/login/`
- Logout: `/users/logout/`
- Dashboard (auth required): `/dashboard/`
## Tailwind Integration Strategy
Phase 2 keeps styling minimal and framework-neutral using `static/css/main.css`.
For upcoming phases, Tailwind will be integrated as a build step that emits compiled CSS into `static/css/` (e.g., via standalone Tailwind CLI or PostCSS in a dedicated frontend tooling container), while templates stay server-rendered.
## Development Commands ## Development Commands
Run tests: Run tests:
@ -73,12 +93,6 @@ Run Django shell:
docker compose exec web python manage.py shell docker compose exec web python manage.py shell
``` ```
Collect static files:
```bash
docker compose exec web python manage.py collectstatic --noinput
```
## Migrations ## Migrations
Create migration: Create migration:
@ -93,10 +107,6 @@ Apply migration:
docker compose exec web python manage.py migrate 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 ## GitFlow
See [CONTRIBUTING.md](CONTRIBUTING.md) for branch model and PR workflow. See [CONTRIBUTING.md](CONTRIBUTING.md) for branch model and PR workflow.

View File

View File

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

View File

View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import CompetitionsHomeView
app_name = "competitions"
urlpatterns = [
path("", CompetitionsHomeView.as_view(), name="index"),
]

View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class CompetitionsHomeView(TemplateView):
template_name = "competitions/index.html"

View File

@ -1,8 +1,11 @@
from django.urls import path from django.urls import path
from .views import health, home from .views import DashboardView, HomeView, health
app_name = "core"
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path("", HomeView.as_view(), name="home"),
path("dashboard/", DashboardView.as_view(), name="dashboard"),
path("health/", health, name="health"), path("health/", health, name="health"),
] ]

View File

@ -1,9 +1,14 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render
def home(request): class HomeView(TemplateView):
return render(request, "home.html") template_name = "core/home.html"
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "core/dashboard.html"
def health(request): def health(request):

View File

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

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

View File

9
apps/ingestion/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import IngestionHomeView
app_name = "ingestion"
urlpatterns = [
path("", IngestionHomeView.as_view(), name="index"),
]

5
apps/ingestion/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class IngestionHomeView(TemplateView):
template_name = "ingestion/index.html"

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

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

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

View File

9
apps/players/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import PlayersHomeView
app_name = "players"
urlpatterns = [
path("", PlayersHomeView.as_view(), name="index"),
]

5
apps/players/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class PlayersHomeView(TemplateView):
template_name = "players/index.html"

View File

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

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

View File

9
apps/providers/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import ProvidersHomeView
app_name = "providers"
urlpatterns = [
path("", ProvidersHomeView.as_view(), name="index"),
]

5
apps/providers/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class ProvidersHomeView(TemplateView):
template_name = "providers/index.html"

View File

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

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

View File

9
apps/scouting/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import ScoutingHomeView
app_name = "scouting"
urlpatterns = [
path("", ScoutingHomeView.as_view(), name="index"),
]

5
apps/scouting/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class ScoutingHomeView(TemplateView):
template_name = "scouting/index.html"

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

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

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

View File

9
apps/stats/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import StatsHomeView
app_name = "stats"
urlpatterns = [
path("", StatsHomeView.as_view(), name="index"),
]

5
apps/stats/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class StatsHomeView(TemplateView):
template_name = "stats/index.html"

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

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

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

View File

9
apps/teams/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from .views import TeamsHomeView
app_name = "teams"
urlpatterns = [
path("", TeamsHomeView.as_view(), name="index"),
]

5
apps/teams/views.py Normal file
View File

@ -0,0 +1,5 @@
from django.views.generic import TemplateView
class TeamsHomeView(TemplateView):
template_name = "teams/index.html"

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

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

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

18
apps/users/forms.py Normal file
View File

@ -0,0 +1,18 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
class SignupForm(UserCreationForm):
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ("username", "email", "password1", "password2")
def save(self, commit=True):
user = super().save(commit=False)
user.email = self.cleaned_data["email"]
if commit:
user.save()
return user

View File

12
apps/users/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from .views import ProfileView, SignupView, UserLoginView, UserLogoutView
app_name = "users"
urlpatterns = [
path("signup/", SignupView.as_view(), name="signup"),
path("login/", UserLoginView.as_view(), name="login"),
path("logout/", UserLogoutView.as_view(), name="logout"),
path("profile/", ProfileView.as_view(), name="profile"),
]

31
apps/users/views.py Normal file
View File

@ -0,0 +1,31 @@
from django.contrib.auth import login
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import reverse_lazy
from django.views.generic import CreateView, TemplateView
from .forms import SignupForm
class SignupView(CreateView):
form_class = SignupForm
template_name = "users/signup.html"
success_url = reverse_lazy("core:dashboard")
def form_valid(self, form):
response = super().form_valid(form)
login(self.request, self.object)
return response
class UserLoginView(LoginView):
template_name = "users/login.html"
redirect_authenticated_user = True
class UserLogoutView(LogoutView):
next_page = reverse_lazy("core:home")
class ProfileView(LoginRequiredMixin, TemplateView):
template_name = "users/profile.html"

View File

@ -19,7 +19,9 @@ def env_list(key: str, default: str = "") -> list[str]:
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure-development-secret") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "insecure-development-secret")
DEBUG = env_bool("DJANGO_DEBUG", False) DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1") 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") CSRF_TRUSTED_ORIGINS = env_list(
"DJANGO_CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1"
)
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@ -29,6 +31,14 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"apps.core", "apps.core",
"apps.users",
"apps.players",
"apps.competitions",
"apps.teams",
"apps.stats",
"apps.scouting",
"apps.providers",
"apps.ingestion",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -86,11 +96,17 @@ USE_TZ = True
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "users:login"
LOGIN_REDIRECT_URL = "core:dashboard"
LOGOUT_REDIRECT_URL = "core:home"
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") 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_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://redis:6379/0")
CELERY_ACCEPT_CONTENT = ["json"] CELERY_ACCEPT_CONTENT = ["json"]

View File

@ -4,4 +4,12 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("apps.core.urls")), path("", include("apps.core.urls")),
path("users/", include("apps.users.urls")),
path("players/", include("apps.players.urls")),
path("competitions/", include("apps.competitions.urls")),
path("teams/", include("apps.teams.urls")),
path("stats/", include("apps.stats.urls")),
path("scouting/", include("apps.scouting.urls")),
path("providers/", include("apps.providers.urls")),
path("ingestion/", include("apps.ingestion.urls")),
] ]

115
static/css/main.css Normal file
View File

@ -0,0 +1,115 @@
:root {
--bg: #f7f8fa;
--surface: #ffffff;
--text: #132033;
--muted: #4f6278;
--line: #dbe2ea;
--brand: #1163d9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg);
color: var(--text);
}
.container {
width: min(960px, 92vw);
margin: 0 auto;
}
.site-header {
background: var(--surface);
border-bottom: 1px solid var(--line);
padding: 0.75rem 0;
}
.row-between {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.row-gap {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.brand {
text-decoration: none;
color: var(--text);
font-weight: 700;
}
a {
color: var(--brand);
}
main {
padding: 1.5rem 0;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1.25rem;
}
.narrow {
max-width: 560px;
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.button,
.link-button {
display: inline-block;
border: 1px solid var(--brand);
background: var(--brand);
color: #fff;
padding: 0.55rem 0.9rem;
border-radius: 8px;
text-decoration: none;
cursor: pointer;
font: inherit;
}
.button.ghost {
background: transparent;
color: var(--brand);
}
.stack p {
margin: 0 0 0.8rem;
}
.stack input {
width: 100%;
padding: 0.55rem;
border: 1px solid var(--line);
border-radius: 8px;
}
.messages {
margin-bottom: 1rem;
}
.message {
background: #e8f3ff;
border: 1px solid #b7d6fa;
padding: 0.6rem 0.8rem;
border-radius: 8px;
}

39
templates/base.html Normal file
View File

@ -0,0 +1,39 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}HoopScout{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<header class="site-header">
<div class="container row-between">
<a class="brand" href="{% url 'core:home' %}">HoopScout</a>
<nav class="row-gap">
<a href="{% url 'players:index' %}">Players</a>
<a href="{% url 'competitions:index' %}">Competitions</a>
<a href="{% url 'teams:index' %}">Teams</a>
<a href="{% url 'scouting:index' %}">Scouting</a>
{% if request.user.is_authenticated %}
<a href="{% url 'core:dashboard' %}">Dashboard</a>
<form method="post" action="{% url 'users:logout' %}">
{% csrf_token %}
<button type="submit" class="link-button">Logout</button>
</form>
{% else %}
<a href="{% url 'users:login' %}">Login</a>
<a href="{% url 'users:signup' %}">Signup</a>
{% endif %}
</nav>
</div>
</header>
<main class="container">
{% include 'partials/messages.html' %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Competitions{% endblock %}
{% block content %}
<section class="panel">
<h1>Competitions</h1>
<p>Competitions module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %}HoopScout | Dashboard{% endblock %}
{% block content %}
<section class="panel">
<h1>Dashboard</h1>
<p>Welcome, {{ request.user.username }}.</p>
<p>This is the authenticated control center for saved searches and watchlists.</p>
</section>
{% endblock %}

18
templates/core/home.html Normal file
View File

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}HoopScout | Home{% endblock %}
{% block content %}
<section class="panel">
<h1>Basketball scouting, server-rendered.</h1>
<p>Search players and track targets using a Django + HTMX workflow.</p>
<div class="actions">
<a class="button" href="{% url 'players:index' %}">Browse players</a>
{% if request.user.is_authenticated %}
<a class="button ghost" href="{% url 'core:dashboard' %}">Go to dashboard</a>
{% else %}
<a class="button ghost" href="{% url 'users:signup' %}">Create account</a>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Ingestion{% endblock %}
{% block content %}
<section class="panel">
<h1>Ingestion</h1>
<p>Ingestion module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% if messages %}
<section class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div>
{% endfor %}
</section>
{% endif %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Players{% endblock %}
{% block content %}
<section class="panel">
<h1>Players</h1>
<p>Players module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Providers{% endblock %}
{% block content %}
<section class="panel">
<h1>Providers</h1>
<p>Providers module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Scouting{% endblock %}
{% block content %}
<section class="panel">
<h1>Scouting</h1>
<p>Scouting module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Stats{% endblock %}
{% block content %}
<section class="panel">
<h1>Stats</h1>
<p>Stats module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}HoopScout | Teams{% endblock %}
{% block content %}
<section class="panel">
<h1>Teams</h1>
<p>Teams module scaffolding for upcoming phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}HoopScout | Login{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>Login</h1>
<form method="post" class="stack">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="button">Sign in</button>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}HoopScout | Profile{% endblock %}
{% block content %}
<section class="panel">
<h1>Profile</h1>
<p>Profile management scaffolding for future phases.</p>
</section>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}HoopScout | Signup{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>Create account</h1>
<form method="post" class="stack">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="button">Create account</button>
</form>
</section>
{% endblock %}

23
tests/test_auth.py Normal file
View File

@ -0,0 +1,23 @@
import pytest
from django.contrib.auth.models import User
from django.urls import reverse
@pytest.mark.django_db
def test_signup_creates_user(client):
payload = {
"username": "scout1",
"email": "scout1@example.com",
"password1": "StrongPass12345",
"password2": "StrongPass12345",
}
response = client.post(reverse("users:signup"), data=payload)
assert response.status_code == 302
assert User.objects.filter(username="scout1").exists()
@pytest.mark.django_db
def test_dashboard_requires_authentication(client):
response = client.get(reverse("core:dashboard"))
assert response.status_code == 302
assert reverse("users:login") in response.url

View File

@ -4,6 +4,6 @@ from django.urls import reverse
@pytest.mark.django_db @pytest.mark.django_db
def test_health_endpoint(client): def test_health_endpoint(client):
response = client.get(reverse("health")) response = client.get(reverse("core:health"))
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "ok" assert response.json()["status"] == "ok"