diff --git a/README.md b/README.md index e1feaf5..762f986 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,34 @@ Production-minded basketball scouting and player search platform built with Djan ## Stack - Python 3.12+ -- Django + Django Templates + HTMX (no SPA) +- Django + Django Templates + HTMX (server-rendered) - PostgreSQL - Redis - Celery + Celery Beat - nginx - Docker / Docker Compose -## Repository Structure +## Project Structure ```text . ├── apps/ +│ ├── core/ +│ ├── users/ +│ ├── players/ +│ ├── competitions/ +│ ├── teams/ +│ ├── stats/ +│ ├── scouting/ +│ ├── providers/ +│ └── ingestion/ ├── config/ +│ └── settings/ ├── nginx/ ├── requirements/ +├── static/ ├── templates/ -├── tests/ -├── docker-compose.yml -├── Dockerfile -└── entrypoint.sh +└── tests/ ``` ## Setup @@ -41,7 +49,7 @@ cp .env.example .env docker compose up --build ``` -3. Apply migrations (if not using auto migrations): +3. Apply migrations (if auto-migrate disabled): ```bash docker compose exec web python manage.py migrate @@ -59,6 +67,18 @@ docker compose exec web python manage.py createsuperuser - Admin: http://localhost/admin/ - 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 Run tests: @@ -73,12 +93,6 @@ Run Django shell: docker compose exec web python manage.py shell ``` -Collect static files: - -```bash -docker compose exec web python manage.py collectstatic --noinput -``` - ## Migrations Create migration: @@ -93,10 +107,6 @@ Apply migration: 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/competitions/__init__.py b/apps/competitions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/competitions/apps.py b/apps/competitions/apps.py new file mode 100644 index 0000000..e56b1ae --- /dev/null +++ b/apps/competitions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CompetitionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.competitions" diff --git a/apps/competitions/migrations/__init__.py b/apps/competitions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/competitions/urls.py b/apps/competitions/urls.py new file mode 100644 index 0000000..98fc737 --- /dev/null +++ b/apps/competitions/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import CompetitionsHomeView + +app_name = "competitions" + +urlpatterns = [ + path("", CompetitionsHomeView.as_view(), name="index"), +] diff --git a/apps/competitions/views.py b/apps/competitions/views.py new file mode 100644 index 0000000..a2e1f26 --- /dev/null +++ b/apps/competitions/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class CompetitionsHomeView(TemplateView): + template_name = "competitions/index.html" diff --git a/apps/core/urls.py b/apps/core/urls.py index 8876b49..ca582f2 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -1,8 +1,11 @@ from django.urls import path -from .views import health, home +from .views import DashboardView, HomeView, health + +app_name = "core" urlpatterns = [ - path("", home, name="home"), + path("", HomeView.as_view(), name="home"), + path("dashboard/", DashboardView.as_view(), name="dashboard"), path("health/", health, name="health"), ] diff --git a/apps/core/views.py b/apps/core/views.py index 722faf4..2fbea41 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -1,9 +1,14 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView from django.http import JsonResponse -from django.shortcuts import render -def home(request): - return render(request, "home.html") +class HomeView(TemplateView): + template_name = "core/home.html" + + +class DashboardView(LoginRequiredMixin, TemplateView): + template_name = "core/dashboard.html" def health(request): diff --git a/apps/ingestion/__init__.py b/apps/ingestion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ingestion/apps.py b/apps/ingestion/apps.py new file mode 100644 index 0000000..57ec18e --- /dev/null +++ b/apps/ingestion/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IngestionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.ingestion" diff --git a/apps/ingestion/migrations/__init__.py b/apps/ingestion/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ingestion/urls.py b/apps/ingestion/urls.py new file mode 100644 index 0000000..07b1307 --- /dev/null +++ b/apps/ingestion/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import IngestionHomeView + +app_name = "ingestion" + +urlpatterns = [ + path("", IngestionHomeView.as_view(), name="index"), +] diff --git a/apps/ingestion/views.py b/apps/ingestion/views.py new file mode 100644 index 0000000..8c9c828 --- /dev/null +++ b/apps/ingestion/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class IngestionHomeView(TemplateView): + template_name = "ingestion/index.html" diff --git a/apps/players/__init__.py b/apps/players/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/players/apps.py b/apps/players/apps.py new file mode 100644 index 0000000..065e3c3 --- /dev/null +++ b/apps/players/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PlayersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.players" diff --git a/apps/players/migrations/__init__.py b/apps/players/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/players/urls.py b/apps/players/urls.py new file mode 100644 index 0000000..fd4ee83 --- /dev/null +++ b/apps/players/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import PlayersHomeView + +app_name = "players" + +urlpatterns = [ + path("", PlayersHomeView.as_view(), name="index"), +] diff --git a/apps/players/views.py b/apps/players/views.py new file mode 100644 index 0000000..17a6890 --- /dev/null +++ b/apps/players/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class PlayersHomeView(TemplateView): + template_name = "players/index.html" diff --git a/apps/providers/__init__.py b/apps/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/apps.py b/apps/providers/apps.py new file mode 100644 index 0000000..0d8c462 --- /dev/null +++ b/apps/providers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProvidersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.providers" diff --git a/apps/providers/migrations/__init__.py b/apps/providers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/urls.py b/apps/providers/urls.py new file mode 100644 index 0000000..5f79102 --- /dev/null +++ b/apps/providers/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ProvidersHomeView + +app_name = "providers" + +urlpatterns = [ + path("", ProvidersHomeView.as_view(), name="index"), +] diff --git a/apps/providers/views.py b/apps/providers/views.py new file mode 100644 index 0000000..924aeae --- /dev/null +++ b/apps/providers/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class ProvidersHomeView(TemplateView): + template_name = "providers/index.html" diff --git a/apps/scouting/__init__.py b/apps/scouting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/scouting/apps.py b/apps/scouting/apps.py new file mode 100644 index 0000000..43ef2d7 --- /dev/null +++ b/apps/scouting/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ScoutingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.scouting" diff --git a/apps/scouting/migrations/__init__.py b/apps/scouting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/scouting/urls.py b/apps/scouting/urls.py new file mode 100644 index 0000000..d76102b --- /dev/null +++ b/apps/scouting/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ScoutingHomeView + +app_name = "scouting" + +urlpatterns = [ + path("", ScoutingHomeView.as_view(), name="index"), +] diff --git a/apps/scouting/views.py b/apps/scouting/views.py new file mode 100644 index 0000000..a39e597 --- /dev/null +++ b/apps/scouting/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class ScoutingHomeView(TemplateView): + template_name = "scouting/index.html" diff --git a/apps/stats/__init__.py b/apps/stats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stats/apps.py b/apps/stats/apps.py new file mode 100644 index 0000000..b8d6de0 --- /dev/null +++ b/apps/stats/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StatsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.stats" diff --git a/apps/stats/migrations/__init__.py b/apps/stats/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stats/urls.py b/apps/stats/urls.py new file mode 100644 index 0000000..2aa08e7 --- /dev/null +++ b/apps/stats/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import StatsHomeView + +app_name = "stats" + +urlpatterns = [ + path("", StatsHomeView.as_view(), name="index"), +] diff --git a/apps/stats/views.py b/apps/stats/views.py new file mode 100644 index 0000000..1b3dd7f --- /dev/null +++ b/apps/stats/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class StatsHomeView(TemplateView): + template_name = "stats/index.html" diff --git a/apps/teams/__init__.py b/apps/teams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/teams/apps.py b/apps/teams/apps.py new file mode 100644 index 0000000..77d9755 --- /dev/null +++ b/apps/teams/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TeamsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.teams" diff --git a/apps/teams/migrations/__init__.py b/apps/teams/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/teams/urls.py b/apps/teams/urls.py new file mode 100644 index 0000000..6bb4c9d --- /dev/null +++ b/apps/teams/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import TeamsHomeView + +app_name = "teams" + +urlpatterns = [ + path("", TeamsHomeView.as_view(), name="index"), +] diff --git a/apps/teams/views.py b/apps/teams/views.py new file mode 100644 index 0000000..6dc9a58 --- /dev/null +++ b/apps/teams/views.py @@ -0,0 +1,5 @@ +from django.views.generic import TemplateView + + +class TeamsHomeView(TemplateView): + template_name = "teams/index.html" diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..37ba421 --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.users" diff --git a/apps/users/forms.py b/apps/users/forms.py new file mode 100644 index 0000000..ad5f4dd --- /dev/null +++ b/apps/users/forms.py @@ -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 diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/urls.py b/apps/users/urls.py new file mode 100644 index 0000000..45b394c --- /dev/null +++ b/apps/users/urls.py @@ -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"), +] diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..098ae95 --- /dev/null +++ b/apps/users/views.py @@ -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" diff --git a/config/settings/base.py b/config/settings/base.py index 40838c7..a083949 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -19,7 +19,9 @@ def env_list(key: str, default: str = "") -> list[str]: 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") +CSRF_TRUSTED_ORIGINS = env_list( + "DJANGO_CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1" +) INSTALLED_APPS = [ "django.contrib.admin", @@ -29,6 +31,14 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "apps.core", + "apps.users", + "apps.players", + "apps.competitions", + "apps.teams", + "apps.stats", + "apps.scouting", + "apps.providers", + "apps.ingestion", ] MIDDLEWARE = [ @@ -86,11 +96,17 @@ USE_TZ = True STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" 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_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://redis:6379/0") CELERY_ACCEPT_CONTENT = ["json"] diff --git a/config/urls.py b/config/urls.py index 32eaaec..20d1a1a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -4,4 +4,12 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.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")), ] diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..dc01508 --- /dev/null +++ b/static/css/main.css @@ -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; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1459a1c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,39 @@ +{% load static %} + + +
+ + +Competitions module scaffolding for upcoming phases.
+Welcome, {{ request.user.username }}.
+This is the authenticated control center for saved searches and watchlists.
+Search players and track targets using a Django + HTMX workflow.
+Ingestion module scaffolding for upcoming phases.
+Players module scaffolding for upcoming phases.
+Providers module scaffolding for upcoming phases.
+Scouting module scaffolding for upcoming phases.
+Stats module scaffolding for upcoming phases.
+Teams module scaffolding for upcoming phases.
+Profile management scaffolding for future phases.
+