phase2: add modular apps, auth scaffolding, and base template routing
This commit is contained in:
44
README.md
44
README.md
@ -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.
|
||||||
|
|||||||
0
apps/competitions/__init__.py
Normal file
0
apps/competitions/__init__.py
Normal file
6
apps/competitions/apps.py
Normal file
6
apps/competitions/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.competitions"
|
||||||
0
apps/competitions/migrations/__init__.py
Normal file
0
apps/competitions/migrations/__init__.py
Normal file
9
apps/competitions/urls.py
Normal file
9
apps/competitions/urls.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import CompetitionsHomeView
|
||||||
|
|
||||||
|
app_name = "competitions"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", CompetitionsHomeView.as_view(), name="index"),
|
||||||
|
]
|
||||||
5
apps/competitions/views.py
Normal file
5
apps/competitions/views.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionsHomeView(TemplateView):
|
||||||
|
template_name = "competitions/index.html"
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
0
apps/ingestion/__init__.py
Normal file
0
apps/ingestion/__init__.py
Normal file
6
apps/ingestion/apps.py
Normal file
6
apps/ingestion/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IngestionConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.ingestion"
|
||||||
0
apps/ingestion/migrations/__init__.py
Normal file
0
apps/ingestion/migrations/__init__.py
Normal file
9
apps/ingestion/urls.py
Normal file
9
apps/ingestion/urls.py
Normal 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
5
apps/ingestion/views.py
Normal 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
0
apps/players/__init__.py
Normal file
6
apps/players/apps.py
Normal file
6
apps/players/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlayersConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.players"
|
||||||
0
apps/players/migrations/__init__.py
Normal file
0
apps/players/migrations/__init__.py
Normal file
9
apps/players/urls.py
Normal file
9
apps/players/urls.py
Normal 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
5
apps/players/views.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
|
||||||
|
class PlayersHomeView(TemplateView):
|
||||||
|
template_name = "players/index.html"
|
||||||
0
apps/providers/__init__.py
Normal file
0
apps/providers/__init__.py
Normal file
6
apps/providers/apps.py
Normal file
6
apps/providers/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.providers"
|
||||||
0
apps/providers/migrations/__init__.py
Normal file
0
apps/providers/migrations/__init__.py
Normal file
9
apps/providers/urls.py
Normal file
9
apps/providers/urls.py
Normal 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
5
apps/providers/views.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersHomeView(TemplateView):
|
||||||
|
template_name = "providers/index.html"
|
||||||
0
apps/scouting/__init__.py
Normal file
0
apps/scouting/__init__.py
Normal file
6
apps/scouting/apps.py
Normal file
6
apps/scouting/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ScoutingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.scouting"
|
||||||
0
apps/scouting/migrations/__init__.py
Normal file
0
apps/scouting/migrations/__init__.py
Normal file
9
apps/scouting/urls.py
Normal file
9
apps/scouting/urls.py
Normal 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
5
apps/scouting/views.py
Normal 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
0
apps/stats/__init__.py
Normal file
6
apps/stats/apps.py
Normal file
6
apps/stats/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StatsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.stats"
|
||||||
0
apps/stats/migrations/__init__.py
Normal file
0
apps/stats/migrations/__init__.py
Normal file
9
apps/stats/urls.py
Normal file
9
apps/stats/urls.py
Normal 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
5
apps/stats/views.py
Normal 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
0
apps/teams/__init__.py
Normal file
6
apps/teams/apps.py
Normal file
6
apps/teams/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TeamsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.teams"
|
||||||
0
apps/teams/migrations/__init__.py
Normal file
0
apps/teams/migrations/__init__.py
Normal file
9
apps/teams/urls.py
Normal file
9
apps/teams/urls.py
Normal 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
5
apps/teams/views.py
Normal 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
0
apps/users/__init__.py
Normal file
6
apps/users/apps.py
Normal file
6
apps/users/apps.py
Normal 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
18
apps/users/forms.py
Normal 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
|
||||||
0
apps/users/migrations/__init__.py
Normal file
0
apps/users/migrations/__init__.py
Normal file
12
apps/users/urls.py
Normal file
12
apps/users/urls.py
Normal 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
31
apps/users/views.py
Normal 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"
|
||||||
@ -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"]
|
||||||
|
|||||||
@ -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
115
static/css/main.css
Normal 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
39
templates/base.html
Normal 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>
|
||||||
10
templates/competitions/index.html
Normal file
10
templates/competitions/index.html
Normal 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 %}
|
||||||
11
templates/core/dashboard.html
Normal file
11
templates/core/dashboard.html
Normal 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
18
templates/core/home.html
Normal 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 %}
|
||||||
10
templates/ingestion/index.html
Normal file
10
templates/ingestion/index.html
Normal 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 %}
|
||||||
7
templates/partials/messages.html
Normal file
7
templates/partials/messages.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{% if messages %}
|
||||||
|
<section class="messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message {{ message.tags }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
10
templates/players/index.html
Normal file
10
templates/players/index.html
Normal 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 %}
|
||||||
10
templates/providers/index.html
Normal file
10
templates/providers/index.html
Normal 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 %}
|
||||||
10
templates/scouting/index.html
Normal file
10
templates/scouting/index.html
Normal 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 %}
|
||||||
10
templates/stats/index.html
Normal file
10
templates/stats/index.html
Normal 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 %}
|
||||||
10
templates/teams/index.html
Normal file
10
templates/teams/index.html
Normal 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 %}
|
||||||
14
templates/users/login.html
Normal file
14
templates/users/login.html
Normal 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 %}
|
||||||
10
templates/users/profile.html
Normal file
10
templates/users/profile.html
Normal 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 %}
|
||||||
14
templates/users/signup.html
Normal file
14
templates/users/signup.html
Normal 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
23
tests/test_auth.py
Normal 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
|
||||||
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user