Compare commits

...

21 Commits

Author SHA1 Message Date
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
4e4fe62f11 Add feature flag for bulk member upload: update MemberLoadView, templates, and settings to use mass_upload flag with django-waffle. 2026-04-12 12:18:24 +02:00
b71bc2afc0 Add bulk member upload functionality: implement MemberLoadView, update routes, templates, and create MassUploadForm for CSV uploads. 2026-04-12 12:10:02 +02:00
4b819d9dd9 Fix paginator "last page" link in member_filter.html template by correcting hx-get attribute 2026-04-12 11:13:48 +02:00
7aa4a4816c Enable member editing functionality: implement MemberEditView, update routes, modify templates for dynamic filtering and superuser badges, and standardize contact info handling. 2026-04-12 11:06:28 +02:00
3d94b9b2d8 Refactor styles in styles.css: fix margin typo, improve focus/hover effects, and standardize dropdown styling with updated colors and shadows. 2026-04-12 10:37:16 +02:00
95e46fe727 Add django-waffle for feature flagging: update dependencies and middleware configuration. 2026-04-12 10:18:53 +02:00
e55c88c742 Update base.html layout: adjust styles for improved height handling, overflow behavior, and consistency in responsive designs. 2026-03-31 22:56:49 +02:00
54 changed files with 1695 additions and 246 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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="Black"> <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)" /> <option name="sdkName" value="uv (TeamForge)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="uv (TeamForge)" project-jdk-type="Python SDK" />
</project> </project>

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <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> </modules>
</component> </component>
</project> </project>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <component name="FacetManager">
<facet type="django" name="Django"> <facet type="django" name="Django">
<configuration> <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 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() application = get_asgi_application()

View File

@@ -14,7 +14,6 @@ from pathlib import Path
from decouple import Csv, config from decouple import Csv, config
from dj_database_url import parse as db_url 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'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -43,6 +42,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"waffle",
"constance", "constance",
"tailwind", "tailwind",
"django_filters", "django_filters",
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
"theme.apps.ThemeConfig", # Tailwind theme app "theme.apps.ThemeConfig", # Tailwind theme app
"members.apps.MembersConfig", "members.apps.MembersConfig",
"backend.apps.BackendConfig", "backend.apps.BackendConfig",
"teams.apps.TeamsConfig",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -62,6 +63,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware", "django_htmx.middleware.HtmxMiddleware",
"waffle.middleware.WaffleMiddleware",
] ]
ROOT_URLCONF = "TeamForge.urls" ROOT_URLCONF = "TeamForge.urls"
@@ -147,10 +149,12 @@ CONSTANCE_CONFIG = {
"TF_DEFAULT_SEASON_MONTH": (config("TF_DEFAULT_SEASON_MONTH", default=8, cast=int), "Default season start month", int), "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_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_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" PHONENUMBER_DEFAULT_FORMAT = "INTERNATIONAL"
PHONENUMBER_DEFAULT_REGION = config("CM_CLUB_COUNTRY_CODE", default="BE", cast=str) PHONENUMBER_DEFAULT_REGION = config("CM_CLUB_COUNTRY_CODE", default="BE", cast=str)
TAILWIND_APP_NAME = "theme" 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 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('backend/', include('backend.urls')), path("backend/", include("backend.urls")),
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
] ]

View File

@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application 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() 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 from .views import MemberAddView, MemberDeleteView, MemberEditView, MemberListView, MemberLoadView
@@ -6,7 +6,7 @@ app_name = "members"
urlpatterns = [ urlpatterns = [
path("", MemberListView.as_view(), name="list"), path("", MemberListView.as_view(), name="list"),
path("add/", MemberAddView.as_view(), name="add"), path("add/", MemberAddView.as_view(), name="add"),
# path("<int:pk>/edit/", MemberEditView.as_view(), name="edit"), path("<int:pk>/edit/", MemberEditView.as_view(), name="edit"),
path("<int:pk>/delete/", MemberDeleteView.as_view(), name="delete"), path("<int:pk>/delete/", MemberDeleteView.as_view(), name="delete"),
# path("load/", MemberLoadView.as_view(), name="load"), path("load/", MemberLoadView.as_view(), name="load"),
] ]

View File

@@ -1,3 +1,5 @@
import csv
import io
from typing import Any from typing import Any
from django.contrib import messages from django.contrib import messages
@@ -5,15 +7,18 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, UpdateView, CreateView from django.views.generic import CreateView, DeleteView, FormView, UpdateView
from django_filters.views import FilterView from django_filters.views import FilterView
from rules.contrib.views import PermissionRequiredMixin from rules.contrib.views import PermissionRequiredMixin
from waffle.mixins import WaffleFlagMixin
from members.filters import MemberFilter from members.filters import MemberFilter
from members.forms import MassUploadForm, MemberForm
from members.models import Member from members.models import Member
from members.forms import MemberForm
from ..mixins import HTMXViewMixin from ..mixins import HTMXViewMixin
class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView): class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
filterset_class = MemberFilter filterset_class = MemberFilter
paginate_by = 50 paginate_by = 50
@@ -25,15 +30,15 @@ class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index")) return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_filterset_kwargs(self, filterset_class) -> dict[str, Any]: def get_filterset_kwargs(self, filterset_class) -> dict[str, Any]:
kwargs = super().get_filterset_kwargs(filterset_class) kwargs = super().get_filterset_kwargs(filterset_class)
filter_values = {} if kwargs["data"] is None else kwargs["data"].dict() filter_values = {} if kwargs["data"] is None else kwargs["data"].dict()
if not filter_values: if not filter_values:
filter_values.update({"user__is_active": "true"}) filter_values.update({"user__is_active": "true"})
kwargs["data"] = filter_values kwargs["data"] = filter_values
return kwargs return kwargs
@@ -47,11 +52,11 @@ class MemberAddView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin,
success_url = reverse_lazy("backend:members:list") success_url = reverse_lazy("backend:members:list")
partial_name = "members/member_form.html#content" partial_name = "members/member_form.html#content"
menu_highlight = "members" menu_highlight = "members"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index")) return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_success_message(self, cleaned_data): def get_success_message(self, cleaned_data):
return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name()) return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name())
@@ -65,14 +70,22 @@ class MemberEditView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin
success_url = reverse_lazy("backend:members:list") success_url = reverse_lazy("backend:members:list")
partial_name = "members/member_form.html#content" partial_name = "members/member_form.html#content"
menu_highlight = "members" menu_highlight = "members"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index")) return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_success_message(self, cleaned_data): def get_success_message(self, cleaned_data):
return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name()) return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name())
def get_initial(self):
initial = super().get_initial()
user = self.get_object().user
initial.update({"first_name": user.first_name, "last_name": user.last_name, "email": user.email, "admin": user.is_superuser})
return initial
class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, DeleteView): class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, DeleteView):
model = Member model = Member
@@ -82,24 +95,55 @@ class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMix
success_url = reverse_lazy("backend:members:list") success_url = reverse_lazy("backend:members:list")
partial_name = "members/member_confirm_delete.html#content" partial_name = "members/member_confirm_delete.html#content"
menu_highlight = "members" menu_highlight = "members"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index")) return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_success_message(self, cleaned_data): def get_success_message(self, cleaned_data):
return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name()) return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name())
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
# Soft delete user # Soft delete user
self.object.user.is_active = False self.object.user.is_active = False
self.object.user.save() self.object.user.save()
# Do not delete the member object # Do not delete the member object
messages.success(self.request, self.get_success_message({"name": self.object.user.get_full_name()})) messages.success(self.request, self.get_success_message({"name": self.object.user.get_full_name()}))
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
class MemberLoadView: ... class MemberLoadView(PermissionRequiredMixin, HTMXViewMixin, SuccessMessageMixin, WaffleFlagMixin, FormView):
form_class = MassUploadForm
permission_required = "members.add_member"
permission_denied_message = _("You do not have permission to view this page.")
success_url = reverse_lazy("backend:members:list")
success_message = _("Members have been added successfully.")
partial_name = "members/member_load.html#content"
menu_highlight = "members"
template_name = "members/member_load.html"
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["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["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"] != "":
member.birthday = member_information["birthday"]
member.save(update_fields=["license", "birthday"])
return super().form_valid(form)

View File

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

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.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. # Create your views here.
@login_required
def index(request): def index(request):
return render(request, "backend/index.html") 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 #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'TeamForge.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "TeamForge.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
raise ImportError( 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
"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) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

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

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__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") 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") 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: class Meta:
model = Member model = Member
fields = ["user__first_name", "user__last_name", "license", "user__is_active"] fields = ["user__first_name", "user__last_name", "license", "user__is_active"]

View File

@@ -3,40 +3,43 @@ from django.utils.translation import gettext_lazy as _
from .models import Member from .models import Member
class MemberForm(forms.ModelForm): class MemberForm(forms.ModelForm):
first_name = forms.CharField(label=_("First name"), max_length=250) first_name = forms.CharField(label=_("First name"), max_length=250)
last_name = forms.CharField(label=_("Last name"), max_length=250) last_name = forms.CharField(label=_("Last name"), max_length=250)
email = forms.EmailField(label=_("Email")) email = forms.EmailField(label=_("Email"))
admin = forms.BooleanField(label=_("Admin?"), required=False, help_text=_("If checked will mark this user as a site admin granting them all permissions")) admin = forms.BooleanField(label=_("Admin?"), required=False, help_text=_("If checked will mark this user as a site admin granting them all permissions"))
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput, required=False) password = forms.CharField(label=_("Password"), widget=forms.PasswordInput, required=False)
password_confirmation = forms.CharField(label=_("Confirm password"), widget=forms.PasswordInput, required=False) password_confirmation = forms.CharField(label=_("Confirm password"), widget=forms.PasswordInput, required=False)
class Meta: class Meta:
model = Member model = Member
fields = ["phone_number", "emergency_phone_number", "license", "birthday", "family_members"] fields = ["phone_number", "emergency_phone_number", "license", "birthday", "family_members"]
localized_fields = fields localized_fields = fields
def save(self, commit: bool = True) -> Member: def save(self, commit: bool = True) -> Member:
password = None password = None
if self.cleaned_data["password"] is not None and self.cleaned_data["password"] != "" and self.cleaned_data["password"] == self.cleaned_data["password_confirmation"]: if self.cleaned_data["password"] is not None and self.cleaned_data["password"] != "" and self.cleaned_data["password"] == self.cleaned_data["password_confirmation"]:
password = self.cleaned_data["password"] password = self.cleaned_data["password"]
member = Member.create(first_name=self.cleaned_data["first_name"], last_name=self.cleaned_data["last_name"], email=self.cleaned_data["email"], password=password, member=self.instance) member = Member.create(first_name=self.cleaned_data["first_name"], last_name=self.cleaned_data["last_name"], email=self.cleaned_data["email"], password=password, member=self.instance)
member.phone_number = self.cleaned_data["phone_number"] member.phone_number = self.cleaned_data["phone_number"]
member.emergency_phone_number = self.cleaned_data["emergency_phone_number"] member.emergency_phone_number = self.cleaned_data["emergency_phone_number"]
member.license = self.cleaned_data["license"] member.license = self.cleaned_data["license"]
member.birthday = self.cleaned_data["birthday"] member.birthday = self.cleaned_data["birthday"]
if self.cleaned_data["admin"]: if self.cleaned_data["admin"]:
member.user.is_superuser = True member.user.is_superuser = True
member.user.save(update_fields=["is_superuser"]) member.user.save(update_fields=["is_superuser"])
member.save(update_fields=["phone_number", "emergency_phone_number", "license", "birthday"]) member.save(update_fields=["phone_number", "emergency_phone_number", "license", "birthday"])
member.family_members.set(self.cleaned_data["family_members"]) member.family_members.set(self.cleaned_data["family_members"])
return member return member
class MassUploadForm(forms.Form):
csv_file = forms.FileField()

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("members", "0004_member_access_token_member_birthday_and_more"), ("members", "0004_member_access_token_member_birthday_and_more"),
] ]
@@ -17,8 +16,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="member", model_name="member",
name="family_members", name="family_members",
field=models.ManyToManyField( field=models.ManyToManyField(blank=True, to="members.member", verbose_name="family members"),
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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('members', '0005_remove_member_family_member_family_members'), ("members", "0005_remove_member_family_member_family_members"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='member', model_name="member",
name='notes', name="notes",
field=models.TextField(blank=True, null=True, verbose_name='notes'), field=models.TextField(blank=True, null=True, verbose_name="notes"),
), ),
] ]

View File

@@ -54,26 +54,19 @@ class Member(RulesModel):
@classmethod @classmethod
def create(cls, first_name: str, last_name: str, email: str, password: Optional[str] = None, member: Optional["Member"] = None) -> "Member": 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""" """Creates a new member based on the provided details"""
if member is not None and member.pk is not None: if member is not None and member.pk is not None:
member.user.first_name = first_name member.user.first_name = first_name
member.user.last_name = last_name member.user.last_name = last_name
member.user.email = email member.user.email = email
member.user.username = email member.user.username = email
if password is not None and password != "": if password is not None and password != "":
member.user.set_password(password) member.user.set_password(password)
else: else:
# First check to see if a user already exists in the system # First check to see if a user already exists in the system
user, created = get_user_model().objects.get_or_create( user, created = get_user_model().objects.get_or_create(username=email, defaults={"first_name": first_name, "last_name": last_name, "email": email})
username=email,
defaults={
"first_name": first_name,
"last_name": last_name,
"email": email
}
)
if not created: if not created:
user.first_name = first_name user.first_name = first_name
@@ -83,7 +76,13 @@ class Member(RulesModel):
if hasattr(user, "member"): if hasattr(user, "member"):
member = 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) user.set_password(password)
else: else:
member = cls() member = cls()
@@ -96,11 +95,11 @@ class Member(RulesModel):
user.set_password(password) user.set_password(password)
member.user = user member.user = user
if not member.user.is_active: if not member.user.is_active:
member.user.is_active = True member.user.is_active = True
member.user.save() member.user.save()
member.save() member.save()
return member return member

View File

@@ -6,4 +6,4 @@ from django.contrib.auth.models import AbstractUser
@rules.predicate @rules.predicate
def is_member_manager(user: Optional[AbstractUser]) -> bool: 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.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from members.models import Member
User = get_user_model() 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.member_manager"))
self.assertTrue(self.user_a.has_perm("members.add_member")) self.assertTrue(self.user_a.has_perm("members.add_member"))
self.assertFalse(self.user_a.is_superuser) 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" name = "teamforge"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Add your description here"
requires-python = ">=3.13" requires-python = ">=3.12"
dependencies = [ dependencies = [
"dj-database-url>=3.0.1", "dj-database-url>=3.0.1",
"django>=6.0", "django>=6.0",
@@ -12,6 +12,7 @@ dependencies = [
"django-htmx>=1.27.0", "django-htmx>=1.27.0",
"django-phonenumber-field[phonenumbers]>=8.4.0", "django-phonenumber-field[phonenumbers]>=8.4.0",
"django-tailwind[cookiecutter,honcho]>=4.4.2", "django-tailwind[cookiecutter,honcho]>=4.4.2",
"django-waffle>=5.0.0",
"pillow>=12.1.0", "pillow>=12.1.0",
"psycopg2-binary>=2.9.11", "psycopg2-binary>=2.9.11",
"python-decouple>=3.8", "python-decouple>=3.8",

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

@@ -39,7 +39,7 @@
</style> </style>
</head> </head>
<body class="flex flex-col h-screen" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body class="flex flex-col h-dvh overflow-hidden" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<!-- NAVBAR --> <!-- NAVBAR -->
<header id="mainNavbar" class="navbar-shrink navbar bg-base-100 sticky top-0 z-50 shadow"> <header id="mainNavbar" class="navbar-shrink navbar bg-base-100 sticky top-0 z-50 shadow">
<div class="flex-none lg:hidden"> <div class="flex-none lg:hidden">
@@ -84,7 +84,7 @@
</div> </div>
</header> </header>
<div class="drawer lg:drawer-open flex-1"> <div class="drawer lg:drawer-open flex-1 min-h-0">
<!-- Hidden checkbox for mobile sidebar --> <!-- Hidden checkbox for mobile sidebar -->
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle"> <input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
@@ -92,7 +92,7 @@
<aside class="drawer-side z-60 lg:z-auto min-w-fit h-full lg:h-fit"> <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> <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 lg:min-h-full"> <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"> <ul class="menu w-full">
{% block sidebar %}{% endblock sidebar %} {% block sidebar %}{% endblock sidebar %}
</ul> </ul>
@@ -100,8 +100,8 @@
</aside> </aside>
<!-- MAIN CONTENT--> <!-- MAIN CONTENT-->
<div class="drawer-content flex w-full"> <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"> <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" %} {% include "backend/partials/messages.html" %}
<div id="content"> <div id="content">
@@ -115,7 +115,7 @@
</div> </div>
<!-- FOOTER --> <!-- FOOTER -->
<footer class="footer footer-center p-4 bg-neutral text-neutral-content mt-auto"> <footer class="footer footer-center p-4 bg-neutral text-neutral-content">
<p>&copy; {% now "Y" %} TeamForge — All rights reserved.</p> <p>&copy; {% now "Y" %} TeamForge — All rights reserved.</p>
</footer> </footer>

View File

@@ -4,6 +4,7 @@
{% load form_field %} {% load form_field %}
{% load avatar %} {% load avatar %}
{% load pagination %} {% load pagination %}
{% load waffle_tags %}
{% block content %} {% block content %}
{% partialdef content inline %} {% partialdef content inline %}
@@ -14,7 +15,7 @@
<h1 class="page-title">{% translate "Members" %}</h1> <h1 class="page-title">{% translate "Members" %}</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border"> <div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<input type="checkbox" /> <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"> <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">
@@ -59,9 +60,11 @@
</div> </div>
<div class="add"> <div class="add">
<a class="btn btn-accent btn-sm grow hidden lg:flex" href=""> {% flag "TF_MASS_UPLOAD" %}
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %} <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">
</a> <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"> <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" %} <i class="fa-solid fa-plus"></i>{% translate "Add member" %}
@@ -89,7 +92,7 @@
{% for member in object_list %} {% for member in object_list %}
<tr class="hover:bg-base-300"> <tr class="hover:bg-base-300">
<td> <td>
<a href=""> <a href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<div class="flex flex-row items-center gap-3"> <div class="flex flex-row items-center gap-3">
<div> <div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name %} {% avatar first_name=member.user.first_name last_name=member.user.last_name %}
@@ -105,7 +108,7 @@
{% endif %} {% endif %}
{% if not member.user.is_active %} {% if not member.user.is_active %}
<div class="badge badge-neutral badge-sm">{% translate "Inactive"%}</div> <div class="badge badge-neutral badge-sm">{% translate "Inactive" %}</div>
{% endif %} {% endif %}
</div> </div>
</a> </a>
@@ -129,7 +132,7 @@
</td> </td>
<td> <td>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<a class="btn btn-outline btn-sm" href=""> <a class="btn btn-outline btn-sm" href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<i class="fa-solid fa-eye"></i>{% translate "Details" %} <i class="fa-solid fa-eye"></i>{% translate "Details" %}
</a> </a>
@@ -151,13 +154,23 @@
{% else %} {% else %}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{% for member in object_list %} {% for member in object_list %}
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href=""> <a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<div> <div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %} {% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %}
</div> </div>
<div class="grow"> <div class="grow">
<div class="font-semibold text-sm">{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}</div> <div class="font-semibold text-sm">
{{ member.user.get_full_name }}
{% if member.license %}
#{{ member.license }}
{% endif %}
{% if member.user.is_superuser %}
<div class="badge badge-xs badge-accent">
<i class="fa-solid fa-user-shield"></i>
</div>
{% endif %}
</div>
<div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div> <div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div>
</div> </div>
@@ -190,7 +203,7 @@
<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 <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="hidden lg:inline">{% translate "next" %}</span><span
class="lg:hidden">&gt;</span></a> 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.num_pages %}" hx-target="#content"><span <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> class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,6 +3,7 @@
{% load i18n %} {% load i18n %}
{% load form_field %} {% load form_field %}
{% load avatar %} {% load avatar %}
{% load waffle_tags %}
{% block content %} {% block content %}
{% partialdef content inline %} {% partialdef content inline %}
@@ -18,14 +19,6 @@
<div class="flex flex-row gap-2 grow justify-center items-center"> <div class="flex flex-row gap-2 grow justify-center items-center">
{% if member %} {% if member %}
<div class="font-bold text-xl">{{ member.user.get_full_name }}</div> <div class="font-bold text-xl">{{ member.user.get_full_name }}</div>
{% if member.user.is_superuser %}
<div class="tooltip" data-tip="{% translate "This user is a site admin" %}">
<div class="badge badge-sm badge-accent">
<i class="fa-solid fa-user-shield"></i>
</div>
</div>
{% endif %}
{% else %} {% else %}
<div class="font-bold text-xl">{% translate "Create new member" %}</div> <div class="font-bold text-xl">{% translate "Create new member" %}</div>
{% endif %} {% endif %}
@@ -33,7 +26,7 @@
<div class="flex min-w-12 justify-end"> <div class="flex min-w-12 justify-end">
{% if member %} {% if member %}
<a class="btn btn-error btn-outline" href="{% url "backend:members:delete" member.pk %}"> <a class="btn btn-error btn-outline" href="{% url "backend:members:delete" member.pk %}" hx-get="{% url "backend:members:delete" member.pk %}" hx-target="#content">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</a> </a>
{% else %} {% else %}
@@ -44,29 +37,28 @@
{% if member %} {% if member %}
<div class="mt-4 lg:hidden flex flex-row gap-2"> <div class="mt-4 lg:hidden flex flex-row gap-2">
<div class="mt-4 lg:hidden flex flex-row gap-2"> {% if member.phone_number %}
{% if member.phone_number %} <a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-outline btn-sm grow">
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-outline btn-sm grow"> <i class="fa-solid fa-phone"></i>
<i class="fa-solid fa-phone"></i> {{ member.phone_number }}
{{ member.phone }}
</a>
{% endif %}
{% 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>
{{ member.emergency_phone_number }}
</a>
{% endif %}
</div>
{% if config.TF_ENABLE_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> </a>
{% endif %} {% endif %}
<div class="flex flex-row items-center mt-8 gap-x-3 hidden lg:flex"> {% 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-star-of-life"></i>
{{ member.emergency_phone_number }}
</a>
{% endif %}
</div>
{% 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>
{% 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 %} {% avatar first_name=member.user.first_name last_name=member.user.last_name %}
<h2 class="page-subtitle border-b-0! mt-0!">{{ member.user.get_full_name }}</h2> <h2 class="page-subtitle border-b-0! mt-0!">{{ member.user.get_full_name }}</h2>
@@ -76,21 +68,21 @@
{% endif %} {% endif %}
<div class="justify-end hidden gap-2 lg:flex lg:flex-row grow"> <div class="justify-end hidden gap-2 lg:flex lg:flex-row grow">
{% if member.phone %} {% if member.phone_number %}
<a href="{{ member.phone.as_rfc3966 }}" class="btn btn-outline btn-info"><i class="fa-solid fa-phone"></i>{{ member.phone }}</a> <a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-outline btn-info"><i class="fa-solid fa-phone"></i>{{ member.phone_number }}</a>
{% endif %} {% endif %}
{% if member.emergency_phone %} {% if member.emergency_phone_number %}
<a href="{{ member.emergency_phone.as_rfc3966 }}" class="btn btn-outline btn-error"><i class="fa-solid fa-file-medical"></i>{{ member.emergency_phone }}</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 %} {% 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"> <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" %} <i class="fa-solid fa-ticket"></i>{% translate "Team Memberships" %}
</a> </a>
{% endif %} {% endswitch %}
<a href="{% url "backend:members:members_delete" member.id %}" class="btn btn-error btn-outline"> <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" %} <i class="fa-solid fa-trash"></i>{% translate "Delete" %}
</a> </a>
</div> </div>
@@ -110,37 +102,38 @@
</div> </div>
{% endif %} {% endif %}
<form method="post"> <form method="post" hx-post>
{% csrf_token %} {% csrf_token %}
<h2 class="page-subtitle">{% translate "Personal information" %}</h2> <h2 class="page-subtitle">{% translate "Personal information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.first_name %} {% form_field form.first_name %}
{% form_field form.last_name %} {% form_field form.last_name %}
{% form_field form.birthday %} {% form_field form.birthday %}
</div> </div>
<h2 class="page-subtitle">{% translate "Contact information" %}</h2> <h2 class="page-subtitle">{% translate "Contact information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.email %} {% form_field form.email %}
{% form_field form.phone_number %} {% form_field form.phone_number %}
{% form_field form.emergency_phone_number %} {% form_field form.emergency_phone_number %}
</div> </div>
<h2 class="page-subtitle">{% translate "Family information" %}</h2> <h2 class="page-subtitle">{% translate "Family information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.family_members %} {% form_field form.family_members %}
</div> </div>
<h2 class="page-subtitle">{% translate "Club information" %}</h2> <h2 class="page-subtitle">{% translate "Club information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.license %} {% form_field form.license %}
{% form_field form.admin show_as_toggle=True %} {% form_field form.admin show_as_toggle=True %}
</div> </div>
<h2 class="page-subtitle">{% translate "Password" %}</h2> <h2 class="page-subtitle">{% translate "Password" %}</h2>
<div class="mt-2 text-sm text-justify">{% blocktranslate %}Setting the password here will overwrite the current password for this member, after changing the member will be prompted to set a new password at the next login.<br /><br />If both fields are empty the current password will not be changed.{% endblocktranslate %}</div> <div class="mt-2 text-sm text-justify">{% blocktranslate %}Setting the password here will overwrite the current password for this member, after changing the member will be prompted to set a new password at the next login.<br/><br/>If both
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2"> fields are empty the current password will not be changed.{% endblocktranslate %}</div>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.password %} {% form_field form.password %}
{% form_field form.password_confirmation %} {% form_field form.password_confirmation %}
</div> </div>

View File

@@ -0,0 +1,56 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Members" %}</h1>
<h2 class="page-subtitle border-b-0! hidden lg:flex">{% translate "Bulk load new member information" %}</h2>
<div class="alert alert-info mt-2 text-sm text-justify">
<i class="text-lg fa-solid fa-info"></i>
<span>
{% blocktranslate %}
Data should be formatted as a .csv file with the following information in the different columns:
<ul class="my-2 list-disc list-inside">
<li>First name</li>
<li>Last name</li>
<li>Email</li>
<li>Birthday (YYYY-MM-DD)</li>
<li>License number</li>
<!-- <li>Team (short name)</li>
<li>Role (abbreviation)</li>
<li>Number</li>
<li>Position (C or A, depending on captain or assistant captain, leave empty if neither)</li> -->
</ul>
{% endblocktranslate %}
</span>
</div>
{% 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" enctype="multipart/form-data">
{% csrf_token %}
<div class="mt-4">
{% form_field form.csv_file %}
</div>
<button class="w-full mt-8 btn btn-neutral" type="submit">
<i class="fa-solid fa-floppy-disk"></i>{% translate "Save" %}
</button>
</form>
{% endpartialdef content %}
{% endblock content %}

View File

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

View File

@@ -226,7 +226,7 @@
.choices[data-type*=text] .choices__button { .choices[data-type*=text] .choices__button {
position: relative; position: relative;
display: inline-block; display: inline-block;
margin: 0-4px 0 2px; margin: 0 -4px 0 2px;
padding-left: 16px; padding-left: 16px;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==); background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);
background-size: 8px; background-size: 8px;
@@ -253,26 +253,16 @@
border-radius: var(--radius-field); border-radius: var(--radius-field);
font-size: 14px; font-size: 14px;
min-height: 44px; min-height: 44px;
overflow: hidden overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
} }
.is-focused .choices__inner, .is-focused .choices__inner,
.is-open .choices__inner { .is-open .choices__inner {
border-color: var(--color-base-content); border-color: var(--color-base-content);
isolation: isolate; box-shadow: 0 0 0 2px var(--color-base-100), 0 0 0 4px var(--color-base-content);
border-radius: var(--radius-field);
} }
.is-focused {
border: 2px solid var(--color-base-content);
border-radius: calc(var(--radius-field) * 2);
margin: -2px;
padding: 2px; /* adjust offset */
}
.is-open .choices__inner { .is-open .choices__inner {
border-radius: var(--radius-field); border-radius: var(--radius-field);
} }
@@ -281,6 +271,13 @@
border-radius: var(--radius-field); border-radius: var(--radius-field);
} }
.is-focused {
border: 0;
margin: 0;
padding: 0;
outline: 0;
}
.choices__list { .choices__list {
margin: 0; margin: 0;
padding-left: 0; padding-left: 0;
@@ -344,16 +341,16 @@
.choices__list--dropdown, .choices__list--dropdown,
.choices__list[aria-expanded] { .choices__list[aria-expanded] {
display: none; display: none;
z-index: 1; z-index: 10;
position: absolute; position: absolute;
width: 100%; width: 100%;
background-color: var(--color-base-100); border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, #0000);
border: 1px solid #ddd;
top: 100%; top: 100%;
margin-top: 3px; margin-top: 0.25rem;
border-radius: var(--radius-field); border-radius: var(--radius-field);
overflow: hidden; overflow: hidden;
word-break: break-all word-break: break-all;
box-shadow: 0 10px 25px color-mix(in oklab, var(--color-base-content) 12%, transparent)
} }
.is-active.choices__list--dropdown, .is-active.choices__list--dropdown,
@@ -363,7 +360,7 @@
.is-open .choices__list--dropdown, .is-open .choices__list--dropdown,
.is-open .choices__list[aria-expanded] { .is-open .choices__list[aria-expanded] {
border-color: #b7b7b7 border-color: color-mix(in oklab, var(--color-base-content) 20%, #0000)
} }
.is-flipped .choices__list--dropdown, .is-flipped .choices__list--dropdown,
@@ -371,8 +368,8 @@
top: auto; top: auto;
bottom: 100%; bottom: 100%;
margin-top: 0; margin-top: 0;
margin-bottom: -1px; margin-bottom: 0.25rem;
border-radius: .25rem .25rem 0 0 border-radius: var(--radius-field)
} }
.choices__list--dropdown .choices__list, .choices__list--dropdown .choices__list,
@@ -387,8 +384,9 @@
.choices__list--dropdown .choices__item, .choices__list--dropdown .choices__item,
.choices__list[aria-expanded] .choices__item { .choices__list[aria-expanded] .choices__item {
position: relative; position: relative;
padding: 10px; padding: 10px 12px;
font-size: 14px font-size: 14px;
color: var(--color-base-content)
} }
[dir=rtl] .choices__list--dropdown .choices__item, [dir=rtl] .choices__list--dropdown .choices__item,
@@ -396,7 +394,7 @@
text-align: right text-align: right
} }
@media (min-width:640px) { @media (min-width: 640px) {
.choices__list--dropdown .choices__item--selectable[data-select-text], .choices__list--dropdown .choices__item--selectable[data-select-text],
.choices__list[aria-expanded] .choices__item--selectable[data-select-text] { .choices__list[aria-expanded] .choices__item--selectable[data-select-text] {
@@ -430,7 +428,7 @@
.choices__list--dropdown .choices__item--selectable.is-highlighted, .choices__list--dropdown .choices__item--selectable.is-highlighted,
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted { .choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
background-color: var(--color-base-100) background-color: color-mix(in oklab, var(--color-primary) 10%, var(--color-base-100))
} }
.choices__list--dropdown .choices__item--selectable.is-highlighted::after, .choices__list--dropdown .choices__item--selectable.is-highlighted::after,
@@ -473,7 +471,8 @@
.choices__button:focus, .choices__button:focus,
.choices__input:focus { .choices__input:focus {
outline: 0 outline: 0;
box-shadow: none;
} }
.choices__input { .choices__input {

View File

@@ -5,29 +5,33 @@ from django import template
register = template.Library() register = template.Library()
def calculate_brightness(background_color: dict) -> float: def calculate_brightness(background_color: dict) -> float:
"""Calculates the brightness of a background image.""" """Calculates the brightness of a background image."""
r_coefficient = 0.241 r_coefficient = 0.241
g_coefficient = 0.691 g_coefficient = 0.691
b_coefficient = 0.068 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: def foreground(background_color: dict) -> dict:
"""Calculates the foreground color based on the background.""" """Calculates the foreground color based on the background."""
black = {"R": 0, "G": 0, "B": 0} black = {"R": 0, "G": 0, "B": 0}
white = {"R": 255, "G": 255, "B": 255} white = {"R": 255, "G": 255, "B": 255}
return black if calculate_brightness(background_color) > 210 else white return black if calculate_brightness(background_color) > 210 else white
def background(text: str) -> dict: def background(text: str) -> dict:
"""Calculates the background color based on the text.""" """Calculates the background color based on the text."""
hash_value = md5(text.encode("utf-8")).hexdigest() hash_value = md5(text.encode("utf-8")).hexdigest()
hash_value_values = (hash_value[:8], hash_value[8:16], hash_value[16:24]) 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) 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]} return {"R": background_color[0], "G": background_color[1], "B": background_color[2]}
@register.inclusion_tag("templatetags/avatar.html") @register.inclusion_tag("templatetags/avatar.html")
def avatar(first_name: str = "", last_name: str = "", initials: str = "", width: str = "md", button: bool = False) -> dict: def avatar(first_name: str = "", last_name: str = "", initials: str = "", width: str = "md", button: bool = False) -> dict:
if initials: if initials:
@@ -36,14 +40,14 @@ def avatar(first_name: str = "", last_name: str = "", initials: str = "", width:
else: else:
display_name = f"{first_name[0]}{last_name[0]}" display_name = f"{first_name[0]}{last_name[0]}"
full_name = f"{first_name} {last_name}" full_name = f"{first_name} {last_name}"
avatar_background = background(full_name) avatar_background = background(full_name)
avatar_foreground = foreground(avatar_background) avatar_foreground = foreground(avatar_background)
return { return {
"name": display_name, "name": display_name,
"width": width, "width": width,
"button": button, "button": button,
"background": "#%02x%02x%02x" % (avatar_background["R"], avatar_background["G"], avatar_background["B"]), # noqa: UP031 "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 "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 = template.Library()
@register.inclusion_tag("templatetags/field.html") @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: 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: if label is not None:
field.label = label field.label = label
if help_text is not None: if help_text is not None:
field.help_text = help_text field.help_text = help_text
field_type = None field_type = None
match field.widget_type: match field.widget_type:
case "select" | "nullbooleanselect" | "radioselect" | "selectmultiple": case "select" | "nullbooleanselect" | "radioselect" | "selectmultiple":
field_type = "select" field_type = "select"
case "checkbox": case "checkbox":
field_type = "checkbox" field_type = "checkbox"
case "textarea" | "markdownx": case "textarea" | "markdownx":
field_type = "textarea" field_type = "textarea"
case "clearablefile": case "clearablefile":
field_type = "file" field_type = "file"
case _: case _:
field_type = "input" field_type = "input"
size_modifier = None size_modifier = None
match size: match size:
case "extra-small": case "extra-small":
size_modifier = "xs" size_modifier = "xs"
case "small": case "small":
size_modifier = "sm" size_modifier = "sm"
case _: case _:
pass 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 = template.Library()
@register.simple_tag @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: 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.""" """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_ = request.GET.copy()
dict_[field] = value dict_[field] = value
if default_field is not None and default_field not in dict_.keys(): if default_field is not None and default_field not in dict_.keys():
dict_[default_field] = default_value dict_[default_field] = default_value
return dict_.urlencode() return dict_.urlencode()

90
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.13" requires-python = ">=3.12"
[[package]] [[package]]
name = "arrow" name = "arrow"
@@ -48,6 +48,22 @@ version = "3.4.6"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/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" }, { 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" } 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" } 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 = [ 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/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/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" }, { 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" },
@@ -318,6 +349,18 @@ honcho = [
{ name = "honcho" }, { name = "honcho" },
] ]
[[package]]
name = "django-waffle"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/e1/6f533da0d4ac89f427dfd9410e39bfc14ae3a23335ecd549d76be4b2a834/django_waffle-5.0.0.tar.gz", hash = "sha256:62f9d00eedf68dafb82657beab56e601bddedc1ea1ccfef91d83df8658708509", size = 37761, upload-time = "2025-06-12T07:38:54.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" },
]
[[package]] [[package]]
name = "honcho" name = "honcho"
version = "2.0.0" version = "2.0.0"
@@ -369,6 +412,17 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/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" }, { 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" },
@@ -439,6 +493,17 @@ version = "12.1.1"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/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" }, { 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" },
@@ -497,6 +562,17 @@ version = "2.9.11"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/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" }, { 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" },
@@ -578,6 +654,16 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/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" }, { 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" },
@@ -701,6 +787,7 @@ dependencies = [
{ name = "django-htmx" }, { name = "django-htmx" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] }, { name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "django-tailwind", extra = ["cookiecutter", "honcho"] }, { name = "django-tailwind", extra = ["cookiecutter", "honcho"] },
{ name = "django-waffle" },
{ name = "pillow" }, { name = "pillow" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "python-decouple" }, { name = "python-decouple" },
@@ -723,6 +810,7 @@ requires-dist = [
{ name = "django-htmx", specifier = ">=1.27.0" }, { name = "django-htmx", specifier = ">=1.27.0" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" }, { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "django-tailwind", extras = ["cookiecutter", "honcho"], specifier = ">=4.4.2" }, { name = "django-tailwind", extras = ["cookiecutter", "honcho"], specifier = ">=4.4.2" },
{ name = "django-waffle", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=12.1.0" }, { name = "pillow", specifier = ">=12.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "python-decouple", specifier = ">=3.8" }, { name = "python-decouple", specifier = ">=3.8" },