Compare commits

..

15 Commits

Author SHA1 Message Date
fd1d3fcc95 Redesign backend layout: dark sidebar + slim topbar
Moves from a floating-card sidebar to a full-height dark sidebar
(bg-neutral) with the club logo at the top and logged-in user pinned
to the bottom. The topbar becomes a slim bar containing only the
mobile hamburger, notifications, and logout — no duplicate logo.

- base.html: DaisyUI drawer with h-screen/overflow-hidden so the
  sidebar is sticky and the content column scrolls independently.
  The #content div is now the white card (bg-base-100 rounded-xl)
  so HTMX innerHTML swaps stay inside it.
- member_filter.html: replaces the .action_bar component with a
  clean flex row (title + Add button) above a plain filter form.
  Table and mobile card list are unchanged.
- styles.css: adds .sidebar-nav scoped overrides for menu link
  hover/active colours on the dark sidebar; reduces h1.page-title
  bottom margin from mb-12 to mb-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:13:33 +02:00
e169d83311 Add /run-teamforge skill with Playwright driver
Adds a run skill that starts the Django dev server, creates a test
superuser, logs in via /admin/login/, and screenshots any page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:01:53 +02:00
198d68e6ff Defer constance config reads in Season.generate_default to avoid DB access at import time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:18:02 +02:00
4aebb96a95 Update python version required 2026-06-07 11:02:39 +02:00
afc2035c00 Update python version required 2026-06-07 10:49:59 +02:00
ef05a6523d Apply ruff formatting and fix unused import linting errors
Remove unused imports flagged by ruff (F401), apply ruff format across all
files, and restore members.signals side-effect import with noqa: F401 so the
post_save signal that auto-creates Member profiles continues to fire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 08:55:28 +02:00
6c0115d4a2 Add Team/TeamRole/TeamMembership/TeamPicture models and fix two test bugs
- Fix Member.create(): post_save signal creates Member immediately on User creation,
  so new users always hit the hasattr branch; check `created` flag to set initial
  password and notes for genuinely new users
- Fix Season.for_date(): replace get() with filter().order_by("-start_date").first()
  to handle overlapping seasons gracefully and raise DoesNotExist when none match
- Add TeamRole, Team, TeamMembership, TeamPicture models with rules permissions
- Add migration 0002 for new models
- Add test suites for members and teams covering all new model behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 08:53:05 +02:00
a02f234411 Fix five confirmed bugs: typo, field name, auth, success message, debug print
- Fix `fist_name` typo in CSV bulk upload (first_name was always None)
- Fix CSV file field name mismatch: `members_data` → `csv_file` (matches form + template)
- Add `@login_required` to backend index view (was unprotected)
- Move `messages.success` inside `if form.is_valid()` (was always shown)
- Remove debug `print(response.headers)` from HTMXViewMixin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:56:51 +02:00
236a28bb3d New file for using Claude code 2026-05-17 23:36:08 +02:00
c95cb24ca7 Extend Season model: add generate_default method for creating seasons with configurable defaults, and _add_months utility for date calculations. 2026-04-26 22:01:32 +02:00
03ed9c74b0 Add Season model to teams app: define fields, constraints, meta options, and utility methods for date range and current season tracking. 2026-04-18 16:01:38 +02:00
fb830710f2 Set up teams app: create app structure, register in settings, and update base template formatting. 2026-04-18 12:36:42 +02:00
72e6388c0c Add configuration page: implement ConfigurationForm, update views, templates, and routes to enable superusers to manage club settings and toggle features using django-waffle. 2026-04-12 16:02:05 +02:00
cb3da371d1 Refactor feature flags: replace config checks with django-waffle switches, update templates and MemberLoadView to use TF_MASS_UPLOAD and TF_ENABLE_TEAMS. Add missing waffle_tags load for member_form.html. 2026-04-12 13:48:43 +02:00
8f9357f1b8 Update emergency phone icon in member_form.html to use fa-star-of-life for improved clarity 2026-04-12 13:38:36 +02:00
52 changed files with 1676 additions and 317 deletions

View File

@@ -0,0 +1,92 @@
---
name: run-teamforge
description: Build, run, and drive TeamForge. Use when asked to start TeamForge, run its tests, take a screenshot of its UI, verify a feature works, or interact with the running app.
---
TeamForge is a Django 6 club-management web app (members + teams). Drive it via `.claude/skills/run-teamforge/driver.mjs` under Playwright. The driver starts the dev server, creates a test superuser, logs in via `/admin/login/`, and screenshots any page.
All paths below are relative to the repo root (`/Users/bernard/Code/PycharmProjects/TeamForge/`).
## Prerequisites
Python (≥3.12) and `uv` must be present. Node.js (≥18) is required for the Playwright driver.
```bash
uv sync # install Python deps
npm install playwright # install Playwright (one-time, at repo root)
npx playwright install chromium # download Chromium browser (one-time, ~90 MB)
```
The app connects to a PostgreSQL database. Connection is in `.env`:
```
DJANGO_DATABASE_URL=postgres://...
DJANGO_DEBUG=True
```
If the database isn't reachable, the server still boots but all page requests fail with 500. The `.env` file must exist; there is no fallback to SQLite in production mode.
## Setup
```bash
uv sync
uv run python manage.py migrate # apply migrations (idempotent)
```
## Run (agent path)
The driver handles everything — server startup, test-user creation, login, and screenshots.
```bash
# Smoke test: starts server, screenshots members + configuration pages
node .claude/skills/run-teamforge/driver.mjs smoke
# Screenshot any page (path or full URL)
node .claude/skills/run-teamforge/driver.mjs screenshot /backend/members/
node .claude/skills/run-teamforge/driver.mjs screenshot /backend/configuration
# Stop the background server
node .claude/skills/run-teamforge/driver.mjs stop
```
Screenshots land in `/tmp/teamforge-shots/<name>.png`. Server log → `/tmp/teamforge-server.log`.
Auth is via a local `skilltest` superuser (password: `skilltest123!`) which the driver creates automatically on first run. The driver logs in through `/admin/login/` — the app does not register `django.contrib.auth` URLs, so `/accounts/login/` returns 404.
## Run (human path)
```bash
uv run python manage.py runserver # → http://localhost:8000
python manage.py tailwind start # separate terminal — required for CSS
```
Log in at `/admin/login/` or create a superuser with `uv run python manage.py createsuperuser`.
## Test
```bash
uv run python manage.py test # → 43 tests, all pass
```
---
## Gotchas
- **`/accounts/login/` returns 404** — the URL conf (`TeamForge/urls.py`) only has `backend/` and `admin/`. The `@login_required` decorator redirects there by default, but `django.contrib.auth.urls` is not included. Always log in via `/admin/login/`. The session cookie then works for all `/backend/` views.
- **`avatar` templatetag crashes on empty names** — `theme/templatetags/avatar.py:41` does `first_name[0]` which raises `IndexError: string index out of range` when either name field is blank. Every Django User used as a logged-in session must have `first_name` and `last_name` set. The driver sets them on the test user automatically.
- **`/backend/` dashboard is a placeholder** — `templates/backend/index.html` contains only "TEST FILE". This is expected during development; the real UI lives at `/backend/members/` and `/backend/configuration`.
- **Tailwind CSS not served in dev** — running `manage.py runserver` without `manage.py tailwind start` in a second terminal means pages render with no styles. The Playwright driver screenshots may look unstyled when Tailwind isn't running. For visual verification, start `tailwind start` first (or accept the unstyled capture).
- **PostgreSQL required** — `DJANGO_DATABASE_URL` in `.env` points to a remote PostgreSQL instance at `192.168.1.100`. If that host isn't reachable, every request returns 500. There is no in-repo SQLite fallback for normal runs; the test suite creates a separate test DB automatically.
- **`npm install playwright` must run at repo root** — the driver imports `playwright` via Node's resolution from `node_modules/` at the repo root. Running it from another directory will cause `ERR_MODULE_NOT_FOUND`.
## Troubleshooting
- **`Cannot find package 'playwright'`**: run `npm install playwright` at the repo root, then retry.
- **`Server did not start within 20s`**: check `/tmp/teamforge-server.log`. Most common cause: PostgreSQL unreachable (connection refused or timeout).
- **500 on every page after login**: the logged-in user has no `first_name`/`last_name` — the avatar templatetag crashes on the base template. Fix: set names on the user via `uv run python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='skilltest'); u.first_name='Skill'; u.last_name='Test'; u.save()"`.
- **`page.waitForURL` timeout on login**: admin login redirect failed — wrong password or user doesn't exist. The driver's `ensureTestUser()` recreates the user each run; if it fails, check that `manage.py shell` works at all.

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* TeamForge driver — launch server, login, drive pages, screenshot.
*
* Usage (run from repo root):
* node .claude/skills/run-teamforge/driver.mjs smoke
* node .claude/skills/run-teamforge/driver.mjs screenshot /backend/members/
* node .claude/skills/run-teamforge/driver.mjs stop
*
* Prerequisites: `npm install playwright` at repo root (one-time).
* Screenshots land in /tmp/teamforge-shots/
* Server log → /tmp/teamforge-server.log
*
* Auth note: logs in via /admin/login/ with a local "skilltest" superuser
* (created automatically on first smoke run). The skilltest user must have
* first_name and last_name set — the avatar templatetag crashes without them.
*/
import { chromium } from 'playwright';
import { execSync, spawn } from 'child_process';
import { writeFileSync, readFileSync, existsSync, openSync, mkdirSync, writeSync, closeSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { fileURLToPath } from 'url';
const PID_FILE = '/tmp/teamforge.pid';
const LOG_FILE = '/tmp/teamforge-server.log';
const SHOTS_DIR = '/tmp/teamforge-shots';
const PORT = 8765;
const BASE_URL = `http://localhost:${PORT}`;
const SKILL_DIR = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = join(SKILL_DIR, '..', '..', '..');
function log(msg) {
process.stderr.write(`[driver] ${msg}\n`);
}
function ensureTestUser() {
const script = [
'from django.contrib.auth.models import User',
"u, _ = User.objects.get_or_create(username='skilltest', defaults={'email': 'test@test.com', 'is_staff': True, 'is_superuser': True, 'first_name': 'Skill', 'last_name': 'Test'})",
"u.first_name = u.first_name or 'Skill'",
"u.last_name = u.last_name or 'Test'",
"u.set_password('skilltest123!')",
'u.save()',
].join('; ');
execSync(`uv run python manage.py shell -c "${script}"`, { cwd: REPO_ROOT, stdio: 'pipe' });
log('skilltest user ready');
}
function pollReady(url, timeoutMs = 20000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
execSync(`curl -sf ${url} -o /dev/null`, { stdio: 'ignore' });
return true;
} catch {}
const end = Date.now() + 400;
while (Date.now() < end) {} // busy-wait
}
return false;
}
function startServer() {
if (existsSync(PID_FILE)) {
try {
const pid = readFileSync(PID_FILE, 'utf8').trim();
execSync(`kill ${pid} 2>/dev/null || true`, { stdio: 'ignore' });
} catch {}
}
log(`Starting dev server on port ${PORT}...`);
const out = openSync(LOG_FILE, 'w');
const proc = spawn('uv', ['run', 'python', 'manage.py', 'runserver', String(PORT)], {
cwd: REPO_ROOT,
detached: true,
stdio: ['ignore', out, out],
});
proc.unref();
writeFileSync(PID_FILE, String(proc.pid));
// Wait for /backend/ (redirects to login when not authed = server is up)
if (!pollReady(`${BASE_URL}/backend/`)) {
throw new Error(`Server did not start within 20s — check ${LOG_FILE}`);
}
log('Server ready.');
}
function stopServer() {
if (!existsSync(PID_FILE)) { log('No PID file — nothing to stop.'); return; }
const pid = readFileSync(PID_FILE, 'utf8').trim();
try {
execSync(`kill ${pid}`, { stdio: 'ignore' });
log(`Killed PID ${pid}`);
} catch {
log(`PID ${pid} already gone`);
}
execSync(`rm -f ${PID_FILE}`, { stdio: 'ignore' });
}
async function getLoggedInPage(browser) {
const context = await browser.newContext();
const page = await context.newPage();
// /accounts/login/ isn't in urlpatterns; use /admin/login/ which is always present
await page.goto(`${BASE_URL}/admin/login/`);
await page.fill('input[name="username"]', 'skilltest');
await page.fill('input[name="password"]', 'skilltest123!');
await page.click('input[type="submit"]');
await page.waitForURL(`${BASE_URL}/admin/`);
log('Logged in.');
return page;
}
async function shot(page, name) {
mkdirSync(SHOTS_DIR, { recursive: true });
const path = join(SHOTS_DIR, `${name}.png`);
await page.screenshot({ path, fullPage: false });
log(`Screenshot → ${path}`);
return path;
}
async function smoke() {
ensureTestUser();
startServer();
const browser = await chromium.launch({ headless: true });
try {
const page = await getLoggedInPage(browser);
// Members list — the main functional page
await page.goto(`${BASE_URL}/backend/members/`);
await page.waitForLoadState('networkidle');
const s1 = await shot(page, 'members');
// Configuration page (superuser only)
await page.goto(`${BASE_URL}/backend/configuration`);
await page.waitForLoadState('networkidle');
const s2 = await shot(page, 'configuration');
log('Smoke test passed.');
console.log(JSON.stringify({ ok: true, screenshots: [s1, s2] }));
} finally {
await browser.close();
}
}
async function screenshotUrl(url) {
ensureTestUser();
startServer();
const browser = await chromium.launch({ headless: true });
try {
const page = await getLoggedInPage(browser);
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
await page.goto(fullUrl);
await page.waitForLoadState('networkidle');
const name = url.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+|_+$/g, '') || 'screenshot';
const path = await shot(page, name);
console.log(JSON.stringify({ ok: true, screenshot: path }));
} finally {
await browser.close();
}
}
const [,, cmd, ...rest] = process.argv;
switch (cmd) {
case 'smoke':
smoke().catch(e => { console.error(e.message); process.exit(1); });
break;
case 'screenshot':
if (!rest[0]) { console.error('Usage: driver.mjs screenshot <path-or-url>'); process.exit(1); }
screenshotUrl(rest[0]).catch(e => { console.error(e.message); process.exit(1); });
break;
case 'stop':
stopServer();
break;
default:
console.error('Usage: driver.mjs [smoke|screenshot <url>|stop]');
process.exit(1);
}

6
.idea/misc.xml generated
View File

@@ -1,11 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="uv (TeamForge) (2)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="uv (TeamForge)" project-jdk-type="Python SDK" />
<component name="RuffConfiguration">
<option name="enabled" value="true" />
<option name="sdkName" value="uv (TeamForge)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="uv (TeamForge)" project-jdk-type="Python SDK" />
</project>

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TeamForge.iml" filepath="$PROJECT_DIR$/.idea/TeamForge.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/teamforge.iml" filepath="$PROJECT_DIR$/.idea/teamforge.iml" />
</modules>
</component>
</project>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Install dependencies
uv sync
# Database setup
python manage.py migrate
python manage.py createsuperuser
# Run dev server (two terminals needed, or use honcho)
python manage.py runserver
python manage.py tailwind start # separate terminal
# Run all tests
python manage.py test
# Run tests for a single app
python manage.py test members
python manage.py test teams
# Coverage
coverage run --source='.' manage.py test && coverage report
# Lint / format (line length 250)
ruff check .
ruff format .
```
## Architecture
TeamForge is a Django 6 club management app (members + teams). Key libraries:
- **HTMX** + **django-htmx**: dynamic interactions without a SPA
- **Tailwind CSS 4 + DaisyUI 5**: styling via `python manage.py tailwind start`
- **django-waffle**: feature flags (`TF_TEAMS`, `TF_ACTIVITIES`, `TF_MASS_UPLOAD`)
- **django-constance**: database-backed config (club name, logo, season defaults)
- **django-rules**: object-level permissions (predicates in `members/rules.py`)
- **django-filter**: URL-driven queryset filtering
### Apps
| App | Responsibility |
|-----|----------------|
| `members` | Member CRUD, CSV bulk import, permissions |
| `teams` | Season model and date-range logic |
| `backend` | Dashboard, configuration view |
| `theme` | Tailwind source, custom template tags |
### HTMX pattern
`HTMXViewMixin` (in `backend/views.py`) is the core convention: views render a full page for normal requests and a named partial for HTMX requests. Partials use django-partials syntax (`template.html#partial_name`). The mixin also injects `HX-Push-Url` and `HX-Trigger` response headers — the trigger drives client-side menu highlighting via a JS event listener.
### Members app details
- Every new `User` automatically gets a `Member` profile via a `post_save` signal.
- Deleting a member deactivates the `User` (`is_active=False`) — no hard deletes.
- `Member.create()` is the canonical way to create a member + user together.
- `MemberManager` always `select_related("user")`.
- Permissions are two-tier: Django model permissions + rules predicates (`is_member_manager`).
### Feature flags
New features should be gated with a `django-waffle` Switch. `WAFFLE_CREATE_MISSING_FLAGS = True` so flags don't need to be pre-created in migrations. Check flags in views with `waffle.switch_is_active("FLAG_NAME")` and in templates with `{% waffle_switch "FLAG_NAME" %}`.
### Configuration
Runtime config (club name, logo, season month/day/duration) lives in `django-constance` and is editable via `/backend/configuration/` (superuser only). Access values with `from constance import config; config.TF_CLUB_NAME`.
### Season duration format
`TF_DEFAULT_SEASON_DURATION` uses the custom format `"<n>y"` (years) or `"<n>m"` (months), parsed in `Season._add_months()`.

View File

@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'TeamForge.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "TeamForge.settings")
application = get_asgi_application()

View File

@@ -14,7 +14,6 @@ from pathlib import Path
from decouple import Csv, config
from dj_database_url import parse as db_url
from django.template.context_processors import static
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -52,6 +51,7 @@ INSTALLED_APPS = [
"theme.apps.ThemeConfig", # Tailwind theme app
"members.apps.MembersConfig",
"backend.apps.BackendConfig",
"teams.apps.TeamsConfig",
]
MIDDLEWARE = [
@@ -149,7 +149,6 @@ CONSTANCE_CONFIG = {
"TF_DEFAULT_SEASON_MONTH": (config("TF_DEFAULT_SEASON_MONTH", default=8, cast=int), "Default season start month", int),
"TF_DEFAULT_SEASON_DAY": (config("TF_DEFAULT_SEASON_DAY", default=1, cast=int), "Default season start day", int),
"TF_DEFAULT_SEASON_DURATION": (config("TF_DEFAULT_SEASON_DURATION", default="1y", cast=str), "Default season duration", str),
"TF_ENABLE_TEAMS": (config("TF_ENABLE_TEAMS", default=True, cast=bool), "Enable teams", bool),
}
PHONENUMBER_DEFAULT_FORMAT = "INTERNATIONAL"
@@ -158,3 +157,4 @@ PHONENUMBER_DEFAULT_REGION = config("CM_CLUB_COUNTRY_CODE", default="BE", cast=s
TAILWIND_APP_NAME = "theme"
WAFFLE_CREATE_MISSING_FLAGS = True
WAFFLE_CREATE_MISSING_SWITCHES = True

View File

@@ -14,10 +14,11 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('backend/', include('backend.urls')),
path('admin/', admin.site.urls),
path("backend/", include("backend.urls")),
path("admin/", admin.site.urls),
]

View File

@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'TeamForge.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "TeamForge.settings")
application = get_wsgi_application()

12
backend/forms.py Normal file
View File

@@ -0,0 +1,12 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class ConfigurationForm(forms.Form):
"""Form instance that holds configuration values for the application."""
club_name = forms.CharField(label=_("Club name"), max_length=250)
club_location = forms.CharField(label=_("Club location"), max_length=250, help_text=_("Changing this setting will set a new home game location and will update already existing games"))
club_logo = forms.ImageField(label=_("Club logo"), required=False)
enable_teams = forms.BooleanField(label=_("Enable teams"), required=False)
enable_activities = forms.BooleanField(label=_("Enable activities"), required=False)

View File

@@ -1,4 +1,4 @@
from django.urls import include, path
from django.urls import path
from .views import MemberAddView, MemberDeleteView, MemberEditView, MemberListView, MemberLoadView

View File

@@ -124,21 +124,21 @@ class MemberLoadView(PermissionRequiredMixin, HTMXViewMixin, SuccessMessageMixin
partial_name = "members/member_load.html#content"
menu_highlight = "members"
template_name = "members/member_load.html"
waffle_flag = "mass_upload"
waffle_flag = "TF_MASS_UPLOAD"
def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index"))
def form_valid(self, form: MassUploadForm) -> HttpResponse:
member_data = self.request.FILES["members_data"]
member_data = self.request.FILES["csv_file"]
with io.TextIOWrapper(member_data.file) as csvfile:
reader = csv.reader(csvfile)
for row in reader:
member_information = {"first_name": row[0], "last_name": row[1], "email": row[2], "birthday": row[3], "license": row[4]}
member = Member.create(first_name=member_information["fist_name"], last_name=member_information["last_name"], email=member_information["email"])
member = Member.create(first_name=member_information["first_name"], last_name=member_information["last_name"], email=member_information["email"])
member.license = member_information["license"]
if member_information["birthday"] is not None and member_information["birthday"] != "":

View File

@@ -4,19 +4,20 @@ from django.http import HttpResponse
from django.template.loader import render_to_string
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
class HTMXPartialMixin:
"""Mixin that automatically switches to a partial template when the request is made via HTMX."""
partial_template_name = None
def get_template_names(self):
# If HTMX request and a partial is defined, return it
if getattr(self.request, "htmx", False) and self.partial_template_name:
return [self.partial_template_name]
return super().get_template_names()
class HTMXViewMixin:
"""
A full-featured HTMX integration mixin for Django CBVs.
@@ -28,83 +29,81 @@ class HTMXViewMixin:
- HX-Refresh
- Graceful fallback to normal Django rendering
"""
# Name of the partial block: template.html#partial_name
partial_name = None
# Optional: automatically push URL on GET
htmx_push_url = None
# Optional: name of the menu item to highlight
menu_highlight = None
# Optional: trigger events after rendering
htmx_trigger = None
htmx_trigger_after_settle = None
htmx_trigger_after_swap = None
# Optional: redirect target for HTMX
htmx_redirect_url = None
# Optional: refresh the page
htmx_refresh = False
def render_partial(self, context):
"""Render a django-partials block."""
request = self.request
return render_to_string(self.partial_name, context, request=request)
def apply_htmx_headers(self, response):
"""Attach HX-* headers to the response."""
request = self.request
if request.htmx:
is_get = request.method == "GET"
is_pagination = "page" in request.GET
if is_get and not is_pagination:
# Push the current path unless overridden
response.headers["HX-Push-Url"] = self.htmx_push_url or request.get_full_path()
print(response.headers)
# Build HX-Trigger payload
trigger_payload = {}
# 1. User-defined triggers
if self.htmx_trigger:
trigger_payload.update(json.loads(self.htmx_trigger))
# 2. Auto menu highlight trigger
if self.menu_highlight:
trigger_payload["menuHighlight"] = self.menu_highlight
# Emit HX-Trigger if anything is present
if trigger_payload:
response.headers["HX-Trigger"] = json.dumps(trigger_payload)
if self.htmx_trigger_after_settle:
response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle
if self.htmx_trigger_after_swap:
response.headers["HX-Trigger-After-Swap"] = self.htmx_trigger_after_swap
return response
def render_to_response(self, context, **response_kwargs):
"""Renders HTMX response, applying headers and handling directives"""
request = self.request
if not request.htmx:
response = super().render_to_response(context, **response_kwargs)
return self.apply_htmx_headers(response)
if self.htmx_redirect_url:
return HttpResponseClientRedirect(self.htmx_redirect_url)
if self.htmx_refresh:
return HttpResponseClientRefresh()
html = self.render_partial(context)
response = HttpResponse(html, **response_kwargs)
return self.apply_htmx_headers(response)
return self.apply_htmx_headers(response)

View File

View File

@@ -1,9 +1,6 @@
from django.urls import include, path
from .views import index
from .views import configuration, index
app_name = "backend"
urlpatterns = [
path("", index, name="index"),
path("members/", include("backend.members.urls")),
]
urlpatterns = [path("", index, name="index"), path("members/", include("backend.members.urls")), path("configuration", configuration, name="configuration")]

View File

@@ -1,6 +1,60 @@
from pathlib import Path
from constance import config
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from waffle.models import Switch
from backend.forms import ConfigurationForm
# Create your views here.
@login_required
def index(request):
return render(request, "backend/index.html")
@login_required
@user_passes_test(lambda u: u.is_superuser)
def configuration(request: HttpRequest) -> HttpResponse:
switches = {
"enable_teams": Switch.objects.get_or_create(name="TF_TEAMS", defaults={"active": False})[0],
"enable_activities": Switch.objects.get_or_create(name="TF_ACTIVITIES", defaults={"active": False})[0],
}
initial_data = {
"club_name": config.TF_CLUB_NAME,
"club_location": config.TF_CLUB_HOME,
"club_logo": config.TF_CLUB_LOGO,
"enable_teams": switches["enable_teams"].active,
"enable_activities": switches["enable_activities"].active,
}
form = ConfigurationForm(initial=initial_data)
if request.method == "POST":
form = ConfigurationForm(request.POST, request.FILES)
if form.is_valid():
config.TF_CLUB_NAME = form.cleaned_data["club_name"]
config.TF_CLUB_HOME = form.cleaned_data["club_location"]
if form.cleaned_data["club_logo"] is not None:
default_storage.save(str(Path(settings.STATIC_ROOT) / form.cleaned_data["club_logo"].name), form.cleaned_data["club_logo"])
config.TF_CLUB_LOGO = form.cleaned_data["club_logo"].name
for switch_key in switches.keys():
if switches[switch_key].active != form.cleaned_data[switch_key]:
switches[switch_key].active = form.cleaned_data[switch_key]
Switch.objects.bulk_update(switches.values(), ["active"])
cache.clear()
messages.success(request=request, message=_("Settings have been saved successfully"))
return render(request, "backend/configuration.html", {"form": form})

View File

@@ -1,22 +1,19 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'TeamForge.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "TeamForge.settings")
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? Did you "
"forget to activate a virtual environment?"
) from exc
raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@@ -5,5 +5,4 @@ class MembersConfig(AppConfig):
name = "members"
def ready(self):
# noinspection PyUnusedImports
import members.signals
import members.signals # noqa: F401

View File

@@ -8,10 +8,18 @@ class MemberFilter(django_filters.FilterSet):
user__first_name = django_filters.CharFilter(field_name="user__first_name", label=_("First name"), lookup_expr="icontains")
user__last_name = django_filters.CharFilter(field_name="user__last_name", label=_("Last name"), lookup_expr="icontains")
license = django_filters.CharFilter(label=_("License"), lookup_expr="icontains")
user__is_active = django_filters.TypedChoiceFilter( field_name='user__is_active', label=_("Active?"), initial="true", choices=( ('', 'All users'), ('true', 'Active users'), ('false', 'Inactive users'), ), coerce=lambda x: x.lower() == 'true' )
user__is_active = django_filters.TypedChoiceFilter(
field_name="user__is_active",
label=_("Active?"),
initial="true",
choices=(
("", "All users"),
("true", "Active users"),
("false", "Inactive users"),
),
coerce=lambda x: x.lower() == "true",
)
class Meta:
model = Member
fields = ["user__first_name", "user__last_name", "license", "user__is_active"]

View File

@@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("members", "0001_initial"),
]

View File

@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("members", "0002_member_family"),
]
@@ -29,8 +28,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="member",
name="family",
field=models.ManyToManyField(
blank=True, to="members.member", verbose_name="family"
),
field=models.ManyToManyField(blank=True, to="members.member", verbose_name="family"),
),
]

View File

@@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("members", "0003_member_created_member_updated_alter_member_family"),
]
@@ -14,9 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="member",
name="access_token",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="access token"
),
field=models.CharField(blank=True, max_length=255, null=True, verbose_name="access token"),
),
migrations.AddField(
model_name="member",
@@ -37,9 +34,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="member",
name="license",
field=models.CharField(
blank=True, max_length=20, null=True, verbose_name="license"
),
field=models.CharField(blank=True, max_length=20, null=True, verbose_name="license"),
),
migrations.AddField(
model_name="member",

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("members", "0004_member_access_token_member_birthday_and_more"),
]
@@ -17,8 +16,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="member",
name="family_members",
field=models.ManyToManyField(
blank=True, to="members.member", verbose_name="family members"
),
field=models.ManyToManyField(blank=True, to="members.member", verbose_name="family members"),
),
]

View File

@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0005_remove_member_family_member_family_members'),
("members", "0005_remove_member_family_member_family_members"),
]
operations = [
migrations.AddField(
model_name='member',
name='notes',
field=models.TextField(blank=True, null=True, verbose_name='notes'),
model_name="member",
name="notes",
field=models.TextField(blank=True, null=True, verbose_name="notes"),
),
]

View File

@@ -54,26 +54,19 @@ class Member(RulesModel):
@classmethod
def create(cls, first_name: str, last_name: str, email: str, password: Optional[str] = None, member: Optional["Member"] = None) -> "Member":
"""Creates a new member based on the provided details"""
if member is not None and member.pk is not None:
member.user.first_name = first_name
member.user.last_name = last_name
member.user.email = email
member.user.username = email
if password is not None and password != "":
member.user.set_password(password)
else:
# First check to see if a user already exists in the system
user, created = get_user_model().objects.get_or_create(
username=email,
defaults={
"first_name": first_name,
"last_name": last_name,
"email": email
}
)
user, created = get_user_model().objects.get_or_create(username=email, defaults={"first_name": first_name, "last_name": last_name, "email": email})
if not created:
user.first_name = first_name
@@ -83,7 +76,13 @@ class Member(RulesModel):
if hasattr(user, "member"):
member = user.member
if password is not None and password != "":
if created:
if password is None or password == "":
initial_password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20))
password = initial_password
member.notes = f"Initial password: {initial_password}"
user.set_password(password)
elif password is not None and password != "":
user.set_password(password)
else:
member = cls()
@@ -96,11 +95,11 @@ class Member(RulesModel):
user.set_password(password)
member.user = user
if not member.user.is_active:
member.user.is_active = True
member.user.save()
member.save()
return member
return member

View File

@@ -6,4 +6,4 @@ from django.contrib.auth.models import AbstractUser
@rules.predicate
def is_member_manager(user: Optional[AbstractUser]) -> bool:
return user.has_perm('members.member_manager')
return user.has_perm("members.member_manager")

View File

@@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from members.models import Member
User = get_user_model()
@@ -28,3 +30,63 @@ class MembersTestCase(TestCase):
self.assertTrue(self.user_a.has_perm("members.member_manager"))
self.assertTrue(self.user_a.has_perm("members.add_member"))
self.assertFalse(self.user_a.is_superuser)
class MemberCreateTest(TestCase):
def test_create_new_member(self):
member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com")
self.assertIsNotNone(member.pk)
self.assertEqual(member.user.first_name, "Alice")
self.assertEqual(member.user.last_name, "Smith")
self.assertEqual(member.user.email, "alice@test.com")
def test_create_sets_initial_password_note(self):
member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com")
self.assertIn("Initial password:", member.notes)
def test_create_with_explicit_password(self):
member = Member.create(first_name="Alice", last_name="Smith", email="alice@test.com", password="secret123")
self.assertTrue(member.user.check_password("secret123"))
self.assertIsNone(member.notes)
def test_create_existing_user_reuses_member(self):
existing_user = User.objects.create(username="bob@test.com", email="bob@test.com", first_name="Bob", last_name="Old")
original_member_pk = existing_user.member.pk
member = Member.create(first_name="Bob", last_name="New", email="bob@test.com")
self.assertEqual(member.pk, original_member_pk)
self.assertEqual(member.user.last_name, "New")
def test_create_update_existing_member(self):
existing_member = Member.create(first_name="Carol", last_name="Old", email="carol@test.com")
updated_member = Member.create(first_name="Carol", last_name="Updated", email="carol@test.com", member=existing_member)
self.assertEqual(updated_member.pk, existing_member.pk)
self.assertEqual(updated_member.user.last_name, "Updated")
def test_create_update_existing_member_with_password(self):
existing_member = Member.create(first_name="Dave", last_name="D", email="dave@test.com")
Member.create(first_name="Dave", last_name="D", email="dave@test.com", password="newpass", member=existing_member)
existing_member.user.refresh_from_db()
self.assertTrue(existing_member.user.check_password("newpass"))
def test_create_reactivates_inactive_user(self):
member = Member.create(first_name="Eve", last_name="E", email="eve@test.com")
member.user.is_active = False
member.user.save()
reactivated = Member.create(first_name="Eve", last_name="E", email="eve@test.com", member=member)
self.assertTrue(reactivated.user.is_active)
def test_create_member_is_active_by_default(self):
member = Member.create(first_name="Frank", last_name="F", email="frank@test.com")
self.assertTrue(member.user.is_active)
class MemberManagerTest(TestCase):
def test_queryset_select_related(self):
User.objects.create(username="user_mgr", first_name="Mgr", last_name="Test", email="mgr@test.com")
# select_related means no extra query is needed to access member.user
with self.assertNumQueries(1):
members = list(Member.objects.all())
_ = [m.user.get_full_name() for m in members]

56
package-lock.json generated Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "TeamForge",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.60.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"playwright": "^1.60.0"
}
}

View File

@@ -2,7 +2,7 @@
name = "teamforge"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
requires-python = ">=3.12"
dependencies = [
"dj-database-url>=3.0.1",
"django>=6.0",

0
teams/__init__.py Normal file
View File

1
teams/admin.py Normal file
View File

@@ -0,0 +1 @@
# Register your models here.

5
teams/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TeamsConfig(AppConfig):
name = "teams"

View File

@@ -0,0 +1,47 @@
# Generated by Django 6.0.3 on 2026-04-18 13:47
import rules.contrib.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Season",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("start_date", models.DateField(verbose_name="start date")),
("end_date", models.DateField(verbose_name="end date")),
],
options={
"verbose_name": "season",
"verbose_name_plural": "seasons",
"ordering": ["start_date"],
"constraints": [
models.UniqueConstraint(
fields=("start_date", "end_date"),
name="season_start_date_end_date_unique",
violation_error_message="Start and end date for a given season must be unique",
),
models.CheckConstraint(
condition=models.Q(("start_date__lt", models.F("end_date"))),
name="season_start_date_lt_end_date",
violation_error_message="Start date must be before end date.",
),
],
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
]

View File

@@ -0,0 +1,102 @@
# Generated by Django 6.0.3 on 2026-06-01 20:21
import django.core.validators
import django.db.models.deletion
import django_extensions.db.fields
import rules.contrib.models
import teams.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("members", "0006_member_notes"),
("teams", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Team",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=255, unique=True, verbose_name="name")),
("short_name", models.CharField(blank=True, help_text="An optional short name for the team", max_length=255, null=True, unique=True, verbose_name="short name")),
("slug", django_extensions.db.fields.AutoSlugField(blank=True, editable=False, max_length=255, populate_from="name", unique=True, verbose_name="slug")),
("logo", models.ImageField(blank=True, null=True, upload_to="teams/logo/", verbose_name="logo")),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "team",
"verbose_name_plural": "teams",
"ordering": ["name"],
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="TeamRole",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=255, unique=True, verbose_name="name")),
("abbreviation", models.CharField(help_text="An abbreviated version of the role name", max_length=255, unique=True, verbose_name="abbreviation")),
("staff_role", models.BooleanField(default=False, help_text="A staff role is any supporting function for a given team (e.g., coach, team manager, ...)", verbose_name="staff role")),
("admin_role", models.BooleanField(default=False, help_text="An admin role is a role that has administrative privileges and can change team information and settings", verbose_name="admin role")),
("sort_order", models.PositiveIntegerField(default=10, help_text="The order in which the role should be displayed (low to high)", verbose_name="sort order")),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "team role",
"verbose_name_plural": "team roles",
"ordering": ["sort_order"],
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.CreateModel(
name="TeamMembership",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("number", models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99)], verbose_name="number")),
("captain", models.BooleanField(default=False, verbose_name="captain")),
("alternate_captain", models.BooleanField(default=False, verbose_name="alternate captain")),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
("member", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="team_memberships", to="members.member", verbose_name="member")),
("season", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="team_memberships", to="teams.season", verbose_name="season")),
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="teams.team", verbose_name="team")),
("role", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="team_memberships", to="teams.teamrole", verbose_name="role")),
],
options={
"verbose_name": "team membership",
"verbose_name_plural": "team memberships",
"ordering": ["team__name", "role__sort_order", "number", "member__user__last_name", "member__user__first_name"],
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.AddField(
model_name="team",
name="members",
field=models.ManyToManyField(through="teams.TeamMembership", to="members.member", verbose_name="members"),
),
migrations.CreateModel(
name="TeamPicture",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("picture", models.ImageField(upload_to=teams.models.team_season_file_path, verbose_name="picture")),
("created", models.DateTimeField(auto_now_add=True)),
("modified", models.DateTimeField(auto_now=True)),
("season", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="teams.season", verbose_name="season")),
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="teams.team", verbose_name="team")),
],
options={
"verbose_name": "team picture",
"verbose_name_plural": "team pictures",
"ordering": ["team__name", "season__start_date"],
},
bases=(rules.contrib.models.RulesModelMixin, models.Model),
),
migrations.AddConstraint(
model_name="teammembership",
constraint=models.UniqueConstraint(fields=("team", "season", "number"), name="unique_team_membership_number", violation_error_message="A team can only have one member for a given season and number."),
),
]

View File

198
teams/models.py Normal file
View File

@@ -0,0 +1,198 @@
import calendar
import datetime
import re
from constance import config
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import AutoSlugField
from rules import is_superuser
from rules.contrib.models import RulesModel
from .rules import is_team_admin
def team_season_file_path(instance: "TeamPicture", filename: str) -> str:
"""Generates the file path for a team picture based on the team name and season."""
return f"teams/picture/{instance.team.slug}/{instance.season.start_date.year}/{filename}"
class Season(RulesModel):
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"))
class Meta:
verbose_name = _("season")
verbose_name_plural = _("seasons")
ordering = ["start_date"]
rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser}
constraints = [
models.UniqueConstraint(fields=["start_date", "end_date"], name="season_start_date_end_date_unique", violation_error_message=_("Start and end date for a given season must be unique")),
models.CheckConstraint(condition=models.Q(start_date__lt=models.F("end_date")), name="season_start_date_lt_end_date", violation_error_message=_("Start date must be before end date.")),
]
def __str__(self):
return _("Season '{start_date} - '{end_date}").format(start_date=self.start_date.strftime("%y"), end_date=self.end_date.strftime("%y"))
@property
def is_current(self) -> bool:
return self.start_date <= timezone.now().date() <= self.end_date
@property
def date_range(self) -> tuple[datetime.date, datetime.date]:
return self.start_date, self.end_date
@classmethod
def for_date(cls, current_date: datetime.date | None = None, values_only: bool = False) -> "tuple[datetime.date, datetime.date] | Season":
if current_date is None:
current_date = timezone.now().date()
season = cls.objects.filter(start_date__lte=current_date, end_date__gte=current_date).order_by("-start_date").first()
if season is None:
raise cls.DoesNotExist(f"No Season covers date {current_date}")
if values_only:
return season.date_range
return season
@classmethod
def generate_default(cls, day: int | None = None, month: int | None = None, duration: str | None = None) -> "Season":
if day is None:
day = config.TF_DEFAULT_SEASON_DAY
if month is None:
month = config.TF_DEFAULT_SEASON_MONTH
if duration is None:
duration = config.TF_DEFAULT_SEASON_DURATION
current_year = timezone.now().date().year
start_date = datetime.date(current_year, month, day)
match = re.fullmatch(r"(\d+)([ym])", duration.strip().lower())
if match is None:
raise ValueError('Duration must be specified as "<number>y" or "<number>m", for example "1y", "1m", or "3m".')
duration_value = int(match.group(1))
duration_unit = match.group(2)
if duration_unit == "y":
end_date = cls._add_months(start_date, duration_value * 12) - datetime.timedelta(days=1)
elif duration_unit == "m":
end_date = cls._add_months(start_date, duration_value) - datetime.timedelta(days=1)
else:
raise ValueError('Duration unit must be "y" or "m".')
return cls.objects.create(start_date=start_date, end_date=end_date)
@staticmethod
def _add_months(date_value: datetime.date, months: int) -> datetime.date:
month_index = date_value.month - 1 + months
year = date_value.year + month_index // 12
month = month_index % 12 + 1
day = min(date_value.day, calendar.monthrange(year, month)[1])
return date_value.replace(year=year, month=month, day=day)
class TeamRole(RulesModel):
name = models.CharField(_("name"), max_length=255, unique=True)
abbreviation = models.CharField(_("abbreviation"), max_length=255, unique=True, help_text=_("An abbreviated version of the role name"))
staff_role = models.BooleanField(_("staff role"), default=False, help_text=_("A staff role is any supporting function for a given team (e.g., coach, team manager, ...)"))
admin_role = models.BooleanField(_("admin role"), default=False, help_text=_("An admin role is a role that has administrative privileges and can change team information and settings"))
sort_order = models.PositiveIntegerField(_("sort order"), default=10, help_text=_("The order in which the role should be displayed (low to high)"))
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("team role")
verbose_name_plural = _("team roles")
ordering = ["sort_order"]
rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser}
def __str__(self):
return f"{self.name} ({self.abbreviation})"
class Team(RulesModel):
name = models.CharField(_("name"), max_length=255, unique=True)
short_name = models.CharField(_("short name"), max_length=255, unique=True, blank=True, null=True, help_text=_("An optional short name for the team"))
slug = AutoSlugField(_("slug"), max_length=255, unique=True, populate_from="name")
logo = models.ImageField(_("logo"), upload_to="teams/logo/", blank=True, null=True)
members = models.ManyToManyField("members.Member", verbose_name=_("members"), through="TeamMembership")
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
ordering = ["name"]
rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser}
def __str__(self):
return self.name
@property
def initials(self) -> str:
words = self.name.split()
if len(words) == 1:
return words[0][0].upper()
return "".join(word[0].upper() for word in words[:2])
@property
def member_count(self) -> int:
season = Season.for_date()
return self.members.filter(team_memberships__season=season).count()
def get_short_name(self) -> str:
"""Returns the short name of the team, or the name if no short name is set."""
return self.short_name if self.short_name and self.short_name != "" else self.name
class TeamMembership(RulesModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, verbose_name=_("team"))
member = models.ForeignKey("members.Member", on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("season"))
role = models.ForeignKey(TeamRole, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("role"))
number = models.PositiveIntegerField(_("number"), blank=True, null=True, validators=[MinValueValidator(1), MaxValueValidator(99)])
captain = models.BooleanField(_("captain"), default=False)
alternate_captain = models.BooleanField(_("alternate captain"), default=False)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("team membership")
verbose_name_plural = _("team memberships")
ordering = ["team__name", "role__sort_order", "number", "member__user__last_name", "member__user__first_name"]
constraints = [
models.UniqueConstraint(fields=["team", "season", "number"], name="unique_team_membership_number", violation_error_message=_("A team can only have one member for a given season and number.")),
]
rules_permissions = {"add": is_superuser | is_team_admin, "view": is_superuser | is_team_admin, "change": is_superuser | is_team_admin, "delete": is_superuser | is_team_admin}
def __str__(self):
return f"{self.team.name} - {self.member.user.get_full_name()} ({self.role.abbreviation})"
class TeamPicture(RulesModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, verbose_name=_("team"))
season = models.ForeignKey(Season, on_delete=models.CASCADE, verbose_name=_("season"))
picture = models.ImageField(_("picture"), upload_to=team_season_file_path)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("team picture")
verbose_name_plural = _("team pictures")
ordering = ["team__name", "season__start_date"]
rules_permissions = {"add": is_superuser, "view": is_superuser, "change": is_superuser, "delete": is_superuser}
def __str__(self):
return f"{self.team.name} - {self.season.start_date.year}"

43
teams/rules.py Normal file
View File

@@ -0,0 +1,43 @@
from typing import TYPE_CHECKING
import rules
from django.contrib.auth.models import AbstractUser
if TYPE_CHECKING:
from .models import TeamMembership
@rules.predicate
def is_team_admin(user: AbstractUser | None, teammembership: "TeamMembership | None") -> bool:
"""
Determine if a user is a team admin within a specific team membership context.
:param user: The user to check for team admin privileges; can be None.
:param teammembership: The specific team membership to evaluate; can be None.
:return: A boolean indicating whether the user is a team admin for the given
team membership.
"""
from .models import TeamMembership, Season
if user is None or teammembership is None:
return False
return TeamMembership.objects.filter(team=teammembership.team, member__user=user, role__admin_role=True, season=Season.for_date()).exists()
@rules.predicate
def is_a_team_admin(user: AbstractUser | None) -> bool:
"""
Determine if a user is a team admin.
:param user: The user to check for team admin privileges; can be None.
:return: A boolean indicating whether the user is a team admin for the given
team membership.
"""
from .models import Season, TeamMembership
if user is None:
return False
return TeamMembership.objects.filter(member__user=user, role__admin_role=True, season=Season.for_date()).exists()

249
teams/tests.py Normal file
View File

@@ -0,0 +1,249 @@
import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from teams.models import Season, Team, TeamMembership, TeamPicture, TeamRole
User = get_user_model()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_member(email, first="Test", last="User"):
user = User.objects.create_user(username=email, email=email, first_name=first, last_name=last)
return user.member
def make_season(start=datetime.date(2025, 9, 1), end=datetime.date(2026, 8, 31)):
return Season.objects.create(start_date=start, end_date=end)
def make_role(name="Player", abbreviation="P", admin_role=False):
return TeamRole.objects.create(name=name, abbreviation=abbreviation, admin_role=admin_role)
def make_team(name="Test Team"):
return Team.objects.create(name=name)
# ---------------------------------------------------------------------------
# Season
# ---------------------------------------------------------------------------
class SeasonStrTest(TestCase):
def test_str(self):
season = Season(start_date=datetime.date(2025, 9, 1), end_date=datetime.date(2026, 8, 31))
self.assertEqual(str(season), "Season '25 - '26")
class SeasonIsCurrentTest(TestCase):
def setUp(self):
today = timezone.now().date()
self.season = Season.objects.create(
start_date=today - datetime.timedelta(days=30),
end_date=today + datetime.timedelta(days=30),
)
def test_is_current_true(self):
self.assertTrue(self.season.is_current)
def test_is_current_false_past(self):
past = Season.objects.create(start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31))
self.assertFalse(past.is_current)
def test_is_current_false_future(self):
future = Season.objects.create(start_date=datetime.date(2099, 1, 1), end_date=datetime.date(2099, 12, 31))
self.assertFalse(future.is_current)
class SeasonDateRangeTest(TestCase):
def test_date_range(self):
start, end = datetime.date(2025, 9, 1), datetime.date(2026, 8, 31)
season = Season(start_date=start, end_date=end)
self.assertEqual(season.date_range, (start, end))
class SeasonForDateTest(TestCase):
def setUp(self):
self.start = datetime.date(2025, 9, 1)
self.end = datetime.date(2026, 8, 31)
self.season = Season.objects.create(start_date=self.start, end_date=self.end)
def test_for_date_returns_season(self):
self.assertEqual(Season.for_date(datetime.date(2026, 1, 15)), self.season)
def test_for_date_values_only(self):
self.assertEqual(Season.for_date(datetime.date(2026, 1, 15), values_only=True), (self.start, self.end))
def test_for_date_defaults_to_today(self):
today = timezone.now().date()
season_today = Season.objects.create(
start_date=today - datetime.timedelta(days=1),
end_date=today + datetime.timedelta(days=364),
)
self.assertEqual(Season.for_date(), season_today)
class SeasonGenerateDefaultTest(TestCase):
def test_generate_year_duration(self):
season = Season.generate_default(day=1, month=9, duration="1y")
self.assertEqual(season.start_date, datetime.date(2026, 9, 1))
self.assertEqual(season.end_date, datetime.date(2027, 8, 31))
def test_generate_month_duration(self):
season = Season.generate_default(day=1, month=9, duration="3m")
self.assertEqual(season.start_date, datetime.date(2026, 9, 1))
self.assertEqual(season.end_date, datetime.date(2026, 11, 30))
def test_generate_invalid_duration_raises(self):
with self.assertRaises(ValueError):
Season.generate_default(day=1, month=9, duration="invalid")
def test_generate_persists_to_db(self):
Season.generate_default(day=1, month=9, duration="1y")
self.assertEqual(Season.objects.count(), 1)
class SeasonAddMonthsTest(TestCase):
def test_add_months_normal(self):
self.assertEqual(Season._add_months(datetime.date(2025, 1, 1), 3), datetime.date(2025, 4, 1))
def test_add_months_year_rollover(self):
self.assertEqual(Season._add_months(datetime.date(2025, 11, 1), 3), datetime.date(2026, 2, 1))
def test_add_months_day_clamp(self):
self.assertEqual(Season._add_months(datetime.date(2025, 1, 31), 1), datetime.date(2025, 2, 28))
def test_add_months_twelve(self):
self.assertEqual(Season._add_months(datetime.date(2025, 9, 1), 12), datetime.date(2026, 9, 1))
# ---------------------------------------------------------------------------
# TeamRole
# ---------------------------------------------------------------------------
class TeamRoleStrTest(TestCase):
def test_str(self):
role = TeamRole(name="Coach", abbreviation="C")
self.assertEqual(str(role), "Coach (C)")
def test_default_flags(self):
role = TeamRole.objects.create(name="Player", abbreviation="P")
self.assertFalse(role.staff_role)
self.assertFalse(role.admin_role)
self.assertEqual(role.sort_order, 10)
# ---------------------------------------------------------------------------
# Team
# ---------------------------------------------------------------------------
class TeamStrTest(TestCase):
def test_str(self):
team = Team(name="Red Dragons")
self.assertEqual(str(team), "Red Dragons")
class TeamInitialsTest(TestCase):
def test_single_word(self):
self.assertEqual(Team(name="Falcons").initials, "F")
def test_two_words(self):
self.assertEqual(Team(name="Red Dragons").initials, "RD")
def test_more_than_two_words_uses_first_two(self):
self.assertEqual(Team(name="The Red Dragons").initials, "TR")
class TeamGetShortNameTest(TestCase):
def test_returns_short_name_when_set(self):
team = Team(name="Red Dragons", short_name="RD")
self.assertEqual(team.get_short_name(), "RD")
def test_falls_back_to_name_when_none(self):
team = Team(name="Red Dragons", short_name=None)
self.assertEqual(team.get_short_name(), "Red Dragons")
def test_falls_back_to_name_when_empty_string(self):
team = Team(name="Red Dragons", short_name="")
self.assertEqual(team.get_short_name(), "Red Dragons")
class TeamMemberCountTest(TestCase):
def setUp(self):
today = timezone.now().date()
self.season = Season.objects.create(
start_date=today - datetime.timedelta(days=30),
end_date=today + datetime.timedelta(days=335),
)
self.team = make_team()
self.role = make_role()
self.member1 = make_member("m1@test.com", "Alice", "One")
self.member2 = make_member("m2@test.com", "Bob", "Two")
def test_member_count(self):
TeamMembership.objects.create(team=self.team, member=self.member1, season=self.season, role=self.role)
TeamMembership.objects.create(team=self.team, member=self.member2, season=self.season, role=self.role)
self.assertEqual(self.team.member_count, 2)
def test_member_count_zero_when_empty(self):
self.assertEqual(self.team.member_count, 0)
# ---------------------------------------------------------------------------
# TeamMembership
# ---------------------------------------------------------------------------
class TeamMembershipStrTest(TestCase):
def setUp(self):
today = timezone.now().date()
self.season = Season.objects.create(
start_date=today - datetime.timedelta(days=30),
end_date=today + datetime.timedelta(days=335),
)
self.team = make_team("Blue Hawks")
self.role = make_role("Forward", "F")
self.member = make_member("player@test.com", "John", "Doe")
self.membership = TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, role=self.role)
def test_str(self):
self.assertEqual(str(self.membership), "Blue Hawks - John Doe (F)")
def test_unique_number_constraint(self):
from django.db import IntegrityError
member2 = make_member("player2@test.com", "Jane", "Doe")
TeamMembership.objects.create(team=self.team, member=member2, season=self.season, role=self.role, number=7)
with self.assertRaises(IntegrityError):
TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, role=self.role, number=7)
def test_captain_default_false(self):
self.assertFalse(self.membership.captain)
self.assertFalse(self.membership.alternate_captain)
# ---------------------------------------------------------------------------
# TeamPicture
# ---------------------------------------------------------------------------
class TeamPictureStrTest(TestCase):
def setUp(self):
self.season = Season.objects.create(
start_date=datetime.date(2025, 9, 1),
end_date=datetime.date(2026, 8, 31),
)
self.team = make_team("Green Eagles")
self.picture = TeamPicture(team=self.team, season=self.season, picture="teams/picture/green-eagles/2025/photo.jpg")
def test_str(self):
self.assertEqual(str(self.picture), "Green Eagles - 2025")

View File

@@ -4,17 +4,20 @@
{% block sidebar %}
{% url "backend:members:list" as members_list %}
{% url "backend:configuration" as configuration %}
{% has_perm "members.member_manager" request.user as is_member_manager %}
{% if is_member_manager %}
<li class="menu-title">Members</li>
<li><a href="{{ members_list }}" class="menu-item {% if members_list in request.path %}menu-active{% endif %}" data-menu="members" hx-get="{% url "backend:members:list" %}" hx-target="#content"><i class="fa-solid fa-users"></i> Members</a></li>
<li><a href="{{ members_list }}" class="menu-item {% if members_list in request.path %}menu-active{% endif %}" data-menu="members" hx-get="{% url "backend:members:list" %}" hx-target="#content"><i class="fa-solid fa-users"></i> Members</a>
</li>
{% endif %}
<li class="menu-title mt-4">Navigation</li>
<li><a href="#"><i class="fa-solid fa-house"></i> Dashboard</a></li>
<li><a href="#"><i class="fa-solid fa-calendar"></i> Calendar</a></li>
<li><a href="#"><i class="fa-solid fa-users"></i> Members</a></li>
<li><a href="#"><i class="fa-solid fa-gear"></i> Settings</a></li>
{% if request.user.is_superuser %}
<li class="menu-title mt-4">Configuration</li>
<li><a href="{% url "backend:configuration" %}" class="menu-item {% if configuration in request.path %}menu-active{% endif %}" data-menu="configuration">
<i class="fa-solid fa-screwdriver-wrench"></i> Settings
</a></li>
{% endif %}
{% endblock sidebar %}

View File

@@ -0,0 +1,76 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% load waffle_tags %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Configuration" %}</h1>
{% blocktranslate %}
<article class="prose text-justify max-w-none">
<p>Use the below options to configure your TeamForge instance. Options marked as required will need to be set in order for TeamForge to function properly.</p>
<p>You can also use this form to enable or disable certain pieces of functionality for your users.</p>
</article>
{% endblocktranslate %}
{% if form.errors %}
<div class="flex flex-row items-center gap-2 p-2 m-4 rounded-lg bg-error">
<i class="mr-2 text-3xl fa-solid fa-exclamation-triangle text-error-content"></i>
<div class="flex flex-col">
<div class="mb-1 font-semibold text-error-content">{% translate "Error" %}</div>
<div class="text-sm text-error-content">{% translate "Please correct the errors below before saving again." %}</div>
</div>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<h2 class="page-subtitle">{% translate "General configuration" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.club_name %}
{% form_field form.club_location %}
{% form_field form.club_logo %}
</div>
<h2 class="page-subtitle">{% translate "Module configuration" %}</h2>
<div class="grid grid-cols-1 gap-1 mt-2 gap-x-4 lg:grid-cols-3 xl:grid-cols-5">
<div class="flex flex-row gap-2">
<span class="font-semibold">{% translate "Teams" %}</span>
{% form_field form.enable_teams show_label=False show_as_toggle=True %}
{% switch "TF_TEAMS" %}
<div class="badge badge-success"><i class="fa-solid fa-check"></i>{% translate "Enabled" %}</div>
{% else %}
<div class="badge badge-error"><i class="fa-solid fa-times"></i>{% translate "Disabled" %}</div>
{% endswitch %}
</div>
<div></div>
<div class="flex flex-row gap-2">
<span class="font-semibold">{% translate "Activities" %}</span>
{% form_field form.enable_activities show_label=False show_as_toggle=True %}
{% switch "TF_ACTIVITIES" %}
<div class="badge badge-success"><i class="fa-solid fa-check"></i>{% translate "Enabled" %}</div>
{% else %}
<div class="badge badge-error"><i class="fa-solid fa-times"></i>{% translate "Disabled" %}</div>
{% endswitch %}
</div>
</div>
<button class="w-full mt-8 btn btn-neutral" type="submit">
<i class="fa-solid fa-floppy-disk"></i>{% translate "Save" %}
</button>
</form>
<script type="text/javascript">
new Choices(document.querySelector("#id_family_members"));
</script>
{% endpartialdef content %}
{% endblock content %}

View File

@@ -4,144 +4,127 @@
{% load django_htmx %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<html lang="en" class="bg-base-200">
<head>
<meta charset="UTF-8">
<meta viewport="width=device-width, initial-scale=1.0">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>
{% if config.TF_CLUB_NAME != "TeamForge" %}{{ config.TF_CLUB_NAME }} - {% endif %}TeamForge
</title>
<title>
{% if config.TF_CLUB_NAME != "TeamForge" %}{{ config.TF_CLUB_NAME }} - {% endif %}TeamForge
</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css"/>
<script src="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/styles/choices.min.css"/>
<script src="https://cdn.jsdelivr.net/npm/choices.js@11.1.0/public/assets/scripts/choices.min.js"></script>
<link href="{% static "css/fontawesome.css" %}" rel="stylesheet"/>
<link href="{% static "css/solid.css" %}" rel="stylesheet"/>
<link href="{% static "css/regular.css" %}" rel="stylesheet"/>
<link href="{% static "css/brands.css" %}" rel="stylesheet"/>
<link href="{% static "css/fontawesome.css" %}" rel="stylesheet"/>
<link href="{% static "css/solid.css" %}" rel="stylesheet"/>
<link href="{% static "css/regular.css" %}" rel="stylesheet"/>
<link href="{% static "css/brands.css" %}" rel="stylesheet"/>
{% tailwind_css %}
{% htmx_script %}
</head>
{% tailwind_css %}
{% htmx_script %}
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<style>
.navbar-shrink {
height: 3rem !important;
transition: height 0.2s ease;
}
<!--
DaisyUI drawer layout:
- drawer-side = dark sidebar (always visible on lg, off-canvas drawer on mobile)
- drawer-content = topbar + scrollable page content + footer
.navbar-normal {
height: 6rem;
transition: height 0.2s ease;
}
</style>
</head>
h-screen + overflow-hidden on .drawer keeps the viewport fixed.
overflow-y-auto on .drawer-content lets the right column scroll independently.
sticky top-0 on the topbar header keeps it pinned within that scrolling column.
-->
<div class="drawer lg:drawer-open h-screen overflow-hidden">
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
<body class="flex flex-col h-dvh overflow-hidden" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<!-- NAVBAR -->
<header id="mainNavbar" class="navbar-shrink navbar bg-base-100 sticky top-0 z-50 shadow">
<div class="flex-none lg:hidden">
<!-- Mobile sidebar toggle -->
<label for="sidebar-toggle" class="btn btn-square btn-ghost">
<i class="fa-solid fa-bars text-xl"></i>
</label>
</div>
<!-- ═══ SIDEBAR ═══════════════════════════════════════════════════════ -->
<div class="drawer-side z-50">
<label for="sidebar-toggle" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex-1 flex items-center gap-3 pl-2">
<img src="{% static config.TF_CLUB_LOGO %}" class="hidden lg:inline h-10 {% if config.TF_CLUB_LOGO != "teamforge/logo.png" %}mask mask-circle{% endif %}" alt="{{ config.TF_CLUB_NAME }} logo">
<span class="text-xl font-bold font-jersey">{{ config.TF_CLUB_NAME }}</span>
</div>
<aside class="w-64 min-h-full bg-neutral text-neutral-content flex flex-col">
<div class="flex flex-row items-center gap-1 lg:gap-4 pr-2">
<!-- Notifications -->
<button class="btn btn-ghost btn-circle">
<i class="fa-solid fa-bell text-xl"></i>
</button>
<!-- Avatar -->
<div class="dropdown dropdown-end">
{% avatar first_name=request.user.first_name last_name=request.user.last_name button=True %}
<ul tabindex="-1" class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li>
<a class="justify-between">
Profile
<span class="badge">New</span>
</a>
</li>
<li><a>Settings</a></li>
<li><a>Logout</a></li>
</ul>
<!-- Brand / club identity -->
<div class="flex items-center gap-3 px-5 py-5 shrink-0 border-b border-white/10">
<img src="{% static config.TF_CLUB_LOGO %}"
class="h-9 w-9 shrink-0 object-cover {% if config.TF_CLUB_LOGO != "teamforge/logo.png" %}mask mask-circle{% else %}mask mask-squircle{% endif %}"
alt="{{ config.TF_CLUB_NAME }} logo">
<span class="text-lg font-bold font-jersey leading-tight">{{ config.TF_CLUB_NAME }}</span>
</div>
<!-- Login/Logout -->
{% if user.is_authenticated %}
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Logout</a>
{% else %}
<a href="" class="btn btn-outline btn-sm hidden lg:flex">Login</a>
{% endif %}
</div>
</header>
<div class="drawer lg:drawer-open flex-1 min-h-0">
<!-- Hidden checkbox for mobile sidebar -->
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
<!-- SIDEBAR -->
<aside class="drawer-side z-60 lg:z-auto min-w-fit h-full lg:h-fit">
<label for="sidebar-toggle" class="drawer-overlay"></label>
<div class="w-64 bg-base-100 border-r p-4 h-full lg:h-fit lg:border lg:border-base-300 lg:m-4 lg:mr-2 lg:rounded-xl">
<ul class="menu w-full">
<!-- Navigation — flex-1 + overflow-y-auto so long nav lists scroll
sidebar-nav scopes the dark-bg colour overrides in styles.css -->
<nav class="flex-1 overflow-y-auto px-3 py-4 sidebar-nav">
<ul class="menu w-full p-0 gap-0.5">
{% block sidebar %}{% endblock sidebar %}
</ul>
</div>
</aside>
</nav>
<!-- MAIN CONTENT-->
<div class="drawer-content flex w-full overflow-y-auto">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full h-fit">
{% include "backend/partials/messages.html" %}
<div id="content">
{% block content %}
<h1 class="text-3xl font-bold">Welcome!</h1>
<p>This is your main content area.</p>
{% endblock %}
<!-- Logged-in user pinned to bottom of sidebar -->
<div class="flex items-center gap-3 px-4 py-4 shrink-0 border-t border-white/10">
{% avatar first_name=request.user.first_name last_name=request.user.last_name %}
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-neutral-content truncate">{{ request.user.get_full_name }}</p>
<p class="text-xs text-neutral-content/50 truncate">{{ request.user.email }}</p>
</div>
</main>
</div>
</div>
</aside>
</div>
<!-- ════════════════════════════════════════════════════════════════════ -->
<!-- FOOTER -->
<footer class="footer footer-center p-4 bg-neutral text-neutral-content">
<p>&copy; {% now "Y" %} TeamForge — All rights reserved.</p>
</footer>
<script>
document.body.addEventListener("menuHighlight", (event) => {
const active = event.detail.value;
document.querySelectorAll(".menu-item").forEach(el => {
el.classList.toggle("menu-active", el.dataset.menu === active);
});
<!-- ═══ MAIN COLUMN ══════════════════════════════════════════════════ -->
<div class="drawer-content flex flex-col overflow-y-auto bg-base-200">
<!-- Slim top bar -->
<header class="navbar bg-base-100 border-b border-base-300 sticky top-0 z-30 shrink-0 min-h-14 px-4 gap-2 shadow-xs">
<!-- Mobile only: open sidebar -->
<label for="sidebar-toggle" class="btn btn-ghost btn-square btn-sm lg:hidden">
<i class="fa-solid fa-bars"></i>
</label>
<div class="flex-1"></div>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-circle btn-sm" aria-label="Notifications">
<i class="fa-solid fa-bell"></i>
</button>
{% if user.is_authenticated %}
<a href="" class="btn btn-ghost btn-sm">Logout</a>
{% else %}
<a href="" class="btn btn-ghost btn-sm">Login</a>
{% endif %}
</div>
</header>
<!-- Page content — the white card is the #content div itself so
HTMX swaps (innerHTML) stay inside the card without re-wrapping -->
<main class="flex-1 p-4 lg:p-6">
<div id="content" class="bg-base-100 rounded-xl p-4 lg:p-6 min-h-full">
{% include "backend/partials/messages.html" %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="text-center text-xs text-base-content/30 py-3 shrink-0">
&copy; {% now "Y" %} TeamForge — All rights reserved.
</footer>
</div>
<!-- ════════════════════════════════════════════════════════════════════ -->
</div>
<script>
// Fired by HTMXViewMixin's HX-Trigger header to highlight the active menu item
document.body.addEventListener("menuHighlight", (event) => {
const active = event.detail.value;
document.querySelectorAll(".menu-item").forEach(el => {
el.classList.toggle("menu-active", el.dataset.menu === active);
});
{% comment %}// Shrinking navbar on scroll
const navbar = document.getElementById("mainNavbar");
let lastScroll = 0; window.addEventListener("scroll", () => {
const current = window.scrollY;
if (current > lastScroll && current > 50) {
navbar.classList.add("navbar-shrink");
navbar.classList.remove("navbar-normal");
} else {
navbar.classList.add("navbar-normal");
navbar.classList.remove("navbar-shrink");
}
lastScroll = current;
});{% endcomment %}
</script>
</body>
</html>
});
</script>
</body>
</html>

View File

@@ -12,13 +12,68 @@
{% include "backend/partials/messages.html" %}
{% endif %}
<h1 class="page-title">{% translate "Members" %}</h1>
<!-- ── Page header: title + primary action ──────────────────────────── -->
<div class="flex items-start justify-between gap-4 mb-5">
<h1 class="text-2xl font-bold tracking-tight">{% translate "Members" %}</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<div class="flex items-center gap-2 shrink-0">
{% flag "TF_MASS_UPLOAD" %}
<a class="btn btn-ghost btn-sm hidden lg:flex"
href="{% url "backend:members:load" %}"
hx-get="{% url "backend:members:load" %}"
hx-target="#content">
<i class="fa-solid fa-file-upload"></i>
{% translate "Load from file" %}
</a>
{% endflag %}
<a class="btn btn-neutral btn-outline btn-sm"
href="{% url "backend:members:add" %}"
hx-get="{% url "backend:members:add" %}"
hx-target="#content">
<i class="fa-solid fa-plus"></i>
{% translate "Add member" %}
</a>
</div>
</div>
<!-- ── Desktop filter row ───────────────────────────────────────────── -->
<!-- Visible on lg+. Sits between the page header and the table. -->
<form class="hidden lg:flex flex-wrap items-end gap-2 mb-4"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
{% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<button type="submit" class="btn btn-outline btn-xs">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs"
href="{% url "backend:members:list" %}"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</form>
<!-- ── Mobile filter (collapsible) ─────────────────────────────────── -->
<div class="lg:hidden collapse collapse-plus bg-base-100 border border-base-300 mb-4">
<input type="checkbox"/>
<div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div>
<div class="collapse-title text-sm font-semibold">
<i class="fa-solid fa-filter mr-2"></i>
{% translate "Filter" %}
{% if filter.is_bound %}
<span class="ml-2 badge badge-sm badge-neutral">active</span>
{% endif %}
</div>
<div class="collapse-content">
<form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<form class="flex flex-col gap-2"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
{% for field in filter.form %}
{% form_field field show_label=False size="small" %}
{% endfor %}
@@ -27,9 +82,11 @@
<button type="submit" class="btn btn-sm btn-outline btn-neutral grow">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<a class="btn btn-outline btn-error btn-sm grow"
href="{% url "backend:members:list" %}"
hx-get="{% url "backend:members:list" %}"
hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
@@ -38,41 +95,8 @@
</div>
</div>
<div class="action_bar">
<div class="filter hidden lg:flex">
<form hx-get="{% url "backend:members:list" %}" hx-target="#content">
{% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %}
{% endfor %}
<div class="flex flex-row w-full gap-2 lg:w-fit">
<button type="submit">
<i class="fa-solid fa-filter"></i>{% translate "Filter" %}
</button>
{% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a>
{% endif %}
</div>
</form>
</div>
<div class="add">
{% flag "mass_upload" %}
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="{% url "backend:members:load" %}" hx-get="{% url "backend:members:load" %}" hx-target="#content">
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
</a>
{% endflag %}
<a class="btn btn-neutral btn-outline btn-sm grow" href="{% url "backend:members:add" %}" hx-get="{% url "backend:members:add" %}" hx-target="#content">
<i class="fa-solid fa-plus"></i>{% translate "Add member" %}
</a>
</div>
</div>
<div class="hidden lg:flex mt-4">
<!-- ── Desktop table ────────────────────────────────────────────────── -->
<div class="hidden lg:block">
<table class="table table-zebra">
<thead>
<tr>
@@ -148,6 +172,7 @@
</table>
</div>
<!-- ── Mobile card list ─────────────────────────────────────────────── -->
<div class="lg:hidden">
{% if object_list|length == 0 %}
<div class="text-center w-full">{% translate "No members found" %}</div>
@@ -183,31 +208,26 @@
{% endif %}
</div>
<!-- ── Pagination ───────────────────────────────────────────────────── -->
{% if is_paginated %}
<div class="flex justify-center mt-4">
<div class="join">
{% if page_obj.has_previous %}
<a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span
class="hidden lg:inline"> {% translate "first" %}</span></a>
<a class="join-item btn"
href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "previous" %}</span><span
class="lg:hidden">&lt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span class="hidden lg:inline"> {% translate "first" %}</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span class="hidden lg:inline">{% translate "previous" %}</span><span class="lg:hidden">&lt;</span></a>
{% endif %}
<button class="join-item btn btn-disabled">
{% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }}
of {{ num_pages }}{% endblocktranslate %}</button>
{% blocktranslate with page=page_obj.number num_pages=page_obj.paginator.num_pages %}page {{ page }} of {{ num_pages }}{% endblocktranslate %}
</button>
{% if page_obj.has_next %}
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "next" %}</span><span
class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-target="#content"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span class="hidden lg:inline">{% translate "next" %}</span><span class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-target="#content"><span class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
{% endpartialdef content %}
{% endblock content %}
{% endblock content %}

View File

@@ -3,6 +3,7 @@
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% load waffle_tags %}
{% block content %}
{% partialdef content inline %}
@@ -45,17 +46,17 @@
{% if member.emergency_phone_number %}
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-error btn-outline btn-sm grow">
<i class="fa-solid fa-file-medical"></i>
<i class="fa-solid fa-star-of-life"></i>
{{ member.emergency_phone_number }}
</a>
{% endif %}
</div>
{% if config.TF_ENABLE_TEAMS %}
{% switch "TF_TEAMS" %}
<a href="?member__user__first_name={{ member.user.first_name }}&member__user__last_name={{ member.user.last_name }}" class="btn btn-sm w-full mt-2 btn-outline btn-neutral lg:hidden">
<i class="fa-solid fa-ticket"></i>{% translate "View team memberships" %}
</a>
{% endif %}
{% endswitch %}
<div class="hidden flex-row items-center mt-8 gap-x-3 hidden lg:flex">
{% avatar first_name=member.user.first_name last_name=member.user.last_name %}
@@ -72,14 +73,14 @@
{% endif %}
{% if member.emergency_phone_number %}
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-outline btn-error"><i class="fa-solid fa-file-medical"></i>{{ member.emergency_phone_number }}</a>
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-outline btn-error"><i class="fa-solid fa-star-of-life"></i>{{ member.emergency_phone_number }}</a>
{% endif %}
{% if config.TF_ENABLE_TEAMS %}
{% switch "TF_TEAMS" %}
<a href="?member__user__first_name={{ member.user.first_name }}&member__user__last_name={{ member.user.last_name }}" class="btn btn-outline btn-neutral">
<i class="fa-solid fa-ticket"></i>{% translate "Team Memberships" %}
</a>
{% endif %}
{% endswitch %}
<a href="{% url "backend:members:delete" member.pk %}" class="btn btn-error btn-outline" hx-get="{% url "backend:members:delete" member.pk %}" hx-target="#content">
<i class="fa-solid fa-trash"></i>{% translate "Delete" %}

View File

@@ -2,4 +2,4 @@ from django.apps import AppConfig
class ThemeConfig(AppConfig):
name = 'theme'
name = "theme"

View File

@@ -62,7 +62,7 @@
@layer components {
h1.page-title {
@apply text-3xl font-bold;
@apply mb-12;
@apply mb-4;
& > svg {
@apply mr-2;
@@ -104,6 +104,39 @@
@apply min-w-fit lg:w-fit;
}
}
/*
* Sidebar navigation — dark background overrides.
* .sidebar-nav wraps the <ul class="menu"> inside the dark aside, so these
* rules only apply there and leave DaisyUI's default menu colours intact
* everywhere else (e.g. inside modals or dropdowns).
*/
.sidebar-nav .menu .menu-title {
color: color-mix(in oklab, var(--color-neutral-content) 40%, transparent);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding-inline: 0.75rem;
}
/* Default nav link on dark sidebar */
.sidebar-nav .menu li > a {
color: color-mix(in oklab, var(--color-neutral-content) 70%, transparent);
border-radius: var(--radius-box);
transition: background-color 0.15s, color 0.15s;
}
/* Hover state */
.sidebar-nav .menu li > a:hover {
background-color: color-mix(in oklab, white 10%, transparent);
color: var(--color-neutral-content);
}
/* Active state (set by template class or JS menuHighlight event) */
.sidebar-nav .menu li > a.menu-active {
background-color: color-mix(in oklab, var(--color-primary) 85%, transparent);
color: var(--color-primary-content);
}
}
.choices {

View File

@@ -5,29 +5,33 @@ from django import template
register = template.Library()
def calculate_brightness(background_color: dict) -> float:
"""Calculates the brightness of a background image."""
r_coefficient = 0.241
g_coefficient = 0.691
b_coefficient = 0.068
return sqrt(r_coefficient ** 2 * background_color["R"] + g_coefficient ** 2 * background_color["G"] + b_coefficient ** 2 * background_color["B"]) * 100
return sqrt(r_coefficient**2 * background_color["R"] + g_coefficient**2 * background_color["G"] + b_coefficient**2 * background_color["B"]) * 100
def foreground(background_color: dict) -> dict:
"""Calculates the foreground color based on the background."""
black = {"R": 0, "G": 0, "B": 0}
white = {"R": 255, "G": 255, "B": 255}
return black if calculate_brightness(background_color) > 210 else white
def background(text: str) -> dict:
"""Calculates the background color based on the text."""
hash_value = md5(text.encode("utf-8")).hexdigest()
hash_value_values = (hash_value[:8], hash_value[8:16], hash_value[16:24])
background_color = tuple(int(value, 16) % 256 for value in hash_value_values)
return {"R": background_color[0], "G": background_color[1], "B": background_color[2]}
@register.inclusion_tag("templatetags/avatar.html")
def avatar(first_name: str = "", last_name: str = "", initials: str = "", width: str = "md", button: bool = False) -> dict:
if initials:
@@ -36,14 +40,14 @@ def avatar(first_name: str = "", last_name: str = "", initials: str = "", width:
else:
display_name = f"{first_name[0]}{last_name[0]}"
full_name = f"{first_name} {last_name}"
avatar_background = background(full_name)
avatar_foreground = foreground(avatar_background)
return {
"name": display_name,
"width": width,
"button": button,
"background": "#%02x%02x%02x" % (avatar_background["R"], avatar_background["G"], avatar_background["B"]), # noqa: UP031
"foreground": "#%02x%02x%02x" % (avatar_foreground["R"], avatar_foreground["G"], avatar_foreground["B"]), # noqa: UP031
}
}

View File

@@ -4,40 +4,41 @@ from typing import Optional
register = template.Library()
@register.inclusion_tag("templatetags/field.html")
def form_field(field: BoundField, label: Optional[str] = None, help_text: Optional[str] = None, show_label: bool = True, show_help_text: bool = True, show_placeholder: bool = True, show_as_toggle: bool = False, size: str = "full") -> dict:
if label is not None:
field.label = label
if help_text is not None:
field.help_text = help_text
field_type = None
match field.widget_type:
case "select" | "nullbooleanselect" | "radioselect" | "selectmultiple":
field_type = "select"
case "checkbox":
field_type = "checkbox"
case "textarea" | "markdownx":
field_type = "textarea"
case "clearablefile":
field_type = "file"
case _:
field_type = "input"
size_modifier = None
match size:
case "extra-small":
size_modifier = "xs"
case "small":
size_modifier = "sm"
case _:
pass
return {"field": field, "field_type": field_type, "size_modifier": size_modifier, "show_label": show_label, "show_help_text": show_help_text, "show_placeholder": show_placeholder, "show_as_toggle": show_as_toggle}
return {"field": field, "field_type": field_type, "size_modifier": size_modifier, "show_label": show_label, "show_help_text": show_help_text, "show_placeholder": show_placeholder, "show_as_toggle": show_as_toggle}

View File

@@ -5,13 +5,14 @@ from django.http import HttpRequest
register = template.Library()
@register.simple_tag
def url_replace(request: HttpRequest, field: str, value: str | int, default_field: Optional[str] = None, default_value: Optional[str | int] = None) -> str:
"""Updates the given field in the GET parameters with the supplied field. If it does not exist, the field is added."""
dict_ = request.GET.copy()
dict_[field] = value
if default_field is not None and default_field not in dict_.keys():
dict_[default_field] = default_value
return dict_.urlencode()
return dict_.urlencode()

76
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.13"
requires-python = ">=3.12"
[[package]]
name = "arrow"
@@ -48,6 +48,22 @@ version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
@@ -145,6 +161,21 @@ version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
@@ -381,6 +412,17 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
@@ -451,6 +493,17 @@ version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
@@ -509,6 +562,17 @@ version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
@@ -590,6 +654,16 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },