This commit is contained in:
martin 2026-02-20 22:08:04 +01:00
parent 3ffeec1344
commit 334dd87994
6 changed files with 1232 additions and 0 deletions

View File

@ -0,0 +1,507 @@
---
name: django-templates
description: Creates Django 6.0 HTML templates with partials, HTMX integration, and modern best practices. Use for template creation, refactoring, or HTMX endpoints.
argument-hint: [template-name] [--partial|--htmx|--base]
allowed-tools: Read, Write, Edit, Glob, Grep
---
# Django 6.0 HTML Templates
Generate production-ready Django 6.0.2 templates with Template Partials, HTMX integration, CSP support, and modern best practices for the league-planner project.
## When to Use
- Creating new HTML templates with Django 6.0 features
- Refactoring templates to use Template Partials
- Building HTMX-powered dynamic components
- Setting up base templates with proper block structure
- Implementing reusable template fragments
## Prerequisites
- Django 6.0+ installed
- Templates directory configured in settings
- For HTMX: `django-htmx` package (optional but recommended)
## Instructions
### Step 1: Analyze Request
Parse `$ARGUMENTS` to determine:
- **Template name**: The target template file
- **Type flag**:
- `--partial`: Create reusable partial fragments
- `--htmx`: HTMX-enabled template with partial endpoints
- `--base`: Base template with block structure
- (none): Standard template
### Step 2: Check Existing Templates
```bash
# Find existing templates in league-planner
find . -path "*/templates/*.html" -type f
```
Review the project's template structure and naming conventions.
### Step 3: Generate Template
Apply the appropriate pattern from the examples below.
---
## Django 6.0 Template Features
### Template Partials (NEW in 6.0)
Define reusable fragments without separate files:
```django
{# Define a partial #}
{% partialdef card %}
<div class="card">
<h3>{{ title }}</h3>
<p>{{ content }}</p>
</div>
{% endpartialdef %}
{# Render the partial multiple times #}
{% partial card %}
{% partial card %}
```
**Inline Option** - Render immediately AND save for reuse:
```django
{% partialdef filter_controls inline %}
<form method="get" class="filters">
{{ filter_form.as_p }}
<button type="submit">Filter</button>
</form>
{% endpartialdef %}
{# Can still reuse later #}
{% partial filter_controls %}
```
### Accessing Partials from Views (HTMX Pattern)
Render only a specific partial:
```python
# views.py
from django.shortcuts import render
def update_component(request, pk):
obj = MyModel.objects.get(pk=pk)
# Render ONLY the partial named "item_row"
return render(request, "myapp/list.html#item_row", {"item": obj})
```
### forloop.length (NEW in 6.0)
Access total loop count:
```django
{% for item in items %}
<div>
{{ item.name }}
<span class="counter">({{ forloop.counter }}/{{ forloop.length }})</span>
</div>
{% endfor %}
```
### querystring Tag Improvements
Build query strings cleanly:
```django
{# Basic usage #}
<a href="?{% querystring page=1 %}">First Page</a>
{# Modify current query params #}
<a href="?{% querystring page=page.next_page_number %}">Next</a>
{# Remove a parameter #}
<a href="?{% querystring filter=None %}">Clear Filter</a>
{# Multiple mappings (NEW in 6.0) #}
<a href="?{% querystring base_params extra_params page=1 %}">Reset</a>
```
### CSP Nonce Support
For inline scripts with Content Security Policy:
```django
{# In settings: add 'django.template.context_processors.csp' #}
<script nonce="{{ csp_nonce }}">
// Safe inline script
</script>
```
---
## Patterns & Best Practices
### Pattern 1: Base Template with Blocks
```django
{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}League Planner{% endblock %}</title>
{% block extra_css %}{% endblock %}
</head>
<body>
{% block navbar %}
{% include "includes/navbar.html" %}
{% endblock %}
<main class="container">
{% block messages %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endblock %}
{% block content %}{% endblock %}
</main>
{% block footer %}
{% include "includes/footer.html" %}
{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>
```
### Pattern 2: List Template with Partials
```django
{# templates/scheduler/scenario_list.html #}
{% extends "base.html" %}
{% block title %}Szenarien{% endblock %}
{% block content %}
<div class="scenario-list">
<h1>Szenarien</h1>
{# Define row partial for HTMX updates #}
{% partialdef scenario_row %}
<tr id="scenario-{{ scenario.pk }}">
<td>{{ scenario.name }}</td>
<td>{{ scenario.season }}</td>
<td>{{ scenario.get_status_display }}</td>
<td>
<a href="{% url 'scenario-detail' scenario.pk %}"
class="btn btn-sm btn-primary">Details</a>
<button hx-delete="{% url 'scenario-delete' scenario.pk %}"
hx-target="#scenario-{{ scenario.pk }}"
hx-swap="outerHTML"
hx-confirm="Wirklich löschen?"
class="btn btn-sm btn-danger">
Löschen
</button>
</td>
</tr>
{% endpartialdef %}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Saison</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="scenario-table-body">
{% for scenario in scenarios %}
{% partial scenario_row %}
{% empty %}
<tr><td colspan="4">Keine Szenarien vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
```
### Pattern 3: HTMX-Powered Form
```django
{# templates/scheduler/scenario_form.html #}
{% extends "base.html" %}
{% block content %}
<div class="form-container">
<h1>{% if scenario.pk %}Szenario bearbeiten{% else %}Neues Szenario{% endif %}</h1>
{# Form partial for HTMX validation #}
{% partialdef scenario_form inline %}
<form method="post"
hx-post="{% url 'scenario-create' %}"
hx-target="#form-container"
hx-swap="innerHTML">
{% csrf_token %}
{% for field in form %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<span class="error">{{ field.errors.0 }}</span>
{% endif %}
{% if field.help_text %}
<small class="help-text">{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if scenario.pk %}Speichern{% else %}Erstellen{% endif %}
</button>
<a href="{% url 'scenario-list' %}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
{% endpartialdef %}
</div>
{% endblock %}
```
### Pattern 4: Detail Page with Inline Editing
```django
{# templates/scheduler/scenario_detail.html #}
{% extends "base.html" %}
{% block content %}
<article class="scenario-detail">
{# Header partial - editable via HTMX #}
{% partialdef scenario_header inline %}
<header id="scenario-header">
<h1>{{ scenario.name }}</h1>
<p class="meta">
Saison: {{ scenario.season }} |
Status: {{ scenario.get_status_display }} |
Erstellt: {{ scenario.created_at|date:"d.m.Y H:i" }}
</p>
<button hx-get="{% url 'scenario-edit-header' scenario.pk %}"
hx-target="#scenario-header"
hx-swap="outerHTML"
class="btn btn-sm btn-outline">
Bearbeiten
</button>
</header>
{% endpartialdef %}
{# Games list partial #}
{% partialdef games_list %}
<section id="games-list" class="games">
<h2>Spiele ({{ games|length }}/{{ games|length }})</h2>
<ul>
{% for game in games %}
<li>
{{ game.home_team }} vs {{ game.away_team }}
<span class="date">{{ game.date|date:"d.m.Y" }}</span>
</li>
{% endfor %}
</ul>
</section>
{% endpartialdef %}
{% partial games_list %}
</article>
{% endblock %}
```
### Pattern 5: Modal with Partial
```django
{# templates/includes/modal.html #}
{% partialdef modal_container %}
<div id="modal-container"
class="modal {% if show %}show{% endif %}"
hx-swap-oob="true">
{% if show %}
<div class="modal-backdrop" hx-get="" hx-target="#modal-container"></div>
<div class="modal-content">
{% block modal_header %}
<header class="modal-header">
<h3>{{ modal_title }}</h3>
<button hx-get="" hx-target="#modal-container" class="close">&times;</button>
</header>
{% endblock %}
{% block modal_body %}
{{ modal_content }}
{% endblock %}
{% block modal_footer %}
<footer class="modal-footer">
<button hx-get="" hx-target="#modal-container" class="btn btn-secondary">
Abbrechen
</button>
{% block modal_actions %}{% endblock %}
</footer>
{% endblock %}
</div>
{% endif %}
</div>
{% endpartialdef %}
```
### Pattern 6: Pagination with querystring
```django
{# templates/includes/pagination.html #}
{% if page_obj.has_other_pages %}
<nav class="pagination" aria-label="Seitennavigation">
<ul>
{% if page_obj.has_previous %}
<li>
<a href="?{% querystring page=1 %}">« Erste</a>
</li>
<li>
<a href="?{% querystring page=page_obj.previous_page_number %}"> Zurück</a>
</li>
{% endif %}
<li class="current">
Seite {{ page_obj.number }} von {{ page_obj.paginator.num_pages }}
</li>
{% if page_obj.has_next %}
<li>
<a href="?{% querystring page=page_obj.next_page_number %}">Weiter </a>
</li>
<li>
<a href="?{% querystring page=page_obj.paginator.num_pages %}">Letzte »</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
```
---
## HTMX View Pattern
For HTMX endpoints that render partials:
```python
# views.py
from django.shortcuts import render, get_object_or_404
from django.views.decorators.http import require_http_methods
@require_http_methods(["GET"])
def scenario_row(request, pk):
"""Render single scenario row partial for HTMX."""
scenario = get_object_or_404(Scenario, pk=pk)
# Syntax: "template.html#partial_name"
return render(request, "scheduler/scenario_list.html#scenario_row", {
"scenario": scenario
})
@require_http_methods(["DELETE"])
def scenario_delete(request, pk):
"""Delete scenario and return empty response for HTMX swap."""
scenario = get_object_or_404(Scenario, pk=pk)
scenario.delete()
# Return empty string - HTMX will remove the row
return HttpResponse("")
@require_http_methods(["GET", "POST"])
def scenario_edit_header(request, pk):
"""Inline edit scenario header via HTMX."""
scenario = get_object_or_404(Scenario, pk=pk)
if request.method == "POST":
form = ScenarioHeaderForm(request.POST, instance=scenario)
if form.is_valid():
form.save()
# Return the display partial
return render(request, "scheduler/scenario_detail.html#scenario_header", {
"scenario": scenario
})
else:
form = ScenarioHeaderForm(instance=scenario)
# Return edit form partial
return render(request, "scheduler/scenario_edit_header.html", {
"scenario": scenario,
"form": form
})
```
---
## Template Organization (league-planner)
```
templates/
├── base.html # Main base template
├── includes/
│ ├── navbar.html
│ ├── footer.html
│ ├── pagination.html
│ └── modal.html
├── scheduler/
│ ├── scenario_list.html # With partials for rows
│ ├── scenario_detail.html # With partials for sections
│ ├── scenario_form.html # With form partial
│ └── _scenario_row.html # Standalone partial (legacy)
├── draws/
│ └── ...
└── qualifiers/
└── ...
```
---
## Common Pitfalls
- **Partial nicht gefunden**: Partial-Namen müssen exakt matchen, keine Leerzeichen
- **Context fehlt**: Partials erben den Context - stelle sicher, dass Variablen verfügbar sind
- **HTMX Swap-Probleme**: Verwende IDs für präzises Targeting (`hx-target="#element-id"`)
- **CSP Violations**: Inline-Styles/Scripts brauchen `nonce="{{ csp_nonce }}"` wenn CSP aktiv
- **N+1 in Templates**: Verwende `select_related`/`prefetch_related` in Views, nicht im Template
- **forloop.length Performance**: Bei großen Listen kann dies teuer sein (zählt vorab)
## Deprecation Warning
⚠️ **urlize Filter**: Default-Protokoll wechselt von HTTP zu HTTPS in Django 7.0.
Setze `URLIZE_ASSUME_HTTPS = True` in settings.py für Vorwärtskompatibilität.
---
## Verification
Test the template:
```bash
# Check for template syntax errors
python manage.py check --deploy
# Render template in shell
python manage.py shell
>>> from django.template.loader import render_to_string
>>> html = render_to_string('scheduler/scenario_list.html', {'scenarios': []})
>>> print(html)
# Test partial rendering
>>> html = render_to_string('scheduler/scenario_list.html#scenario_row', {'scenario': scenario})
```

View File

@ -0,0 +1,123 @@
# qualifiers-model
Erstellt und erweitert Django Models für das qualifiers-Modul (UEFA Qualifikationsturniere).
## Trigger
- Neue QNode, QMatch, QGame, QGrouping Models
- Model-Erweiterungen für Qualifiers
- "qualifiers model", "qnode", "qmatch", "qgame"
## Kontext
Das qualifiers-Modul verwaltet UEFA-Qualifikationsturniere mit folgender Hierarchie:
- **QPath** → QTier → QNode (Turnier-Struktur)
- **QNode** → QGrouping → QPosition (Gruppenbildung)
- **QNode** → QMatch → QGame (Spielpaarungen)
## Regeln
1. **Immer db_index für ForeignKeys setzen** wenn häufig gefiltert wird:
```python
scenario = models.ForeignKey('scheduler.Scenario', on_delete=models.CASCADE, db_index=True)
```
2. **Meta-Klasse mit indexes und constraints**:
```python
class Meta:
ordering = ['-tier__ypos', '-stage__xpos']
constraints = [
models.UniqueConstraint(fields=['name', 'scenario'], name='unique_qnode_name_scenario')
]
indexes = [
models.Index(fields=['scenario', 'tier', 'stage'], name='qnode_scenario_tier_stage_idx'),
]
```
3. **related_name konsistent benennen**:
- Plural für ForeignKey: `related_name='groupings'`
- Model-Name für M2M: `related_name='qnodes_seeded'`
4. **State-Machine Pattern für QNode**:
```python
NODESTATE_CHOICES = (
(0, "none"), # Warten auf Vorgänger
(1, "seeded"), # Teams zugeordnet
(2, "grouping"), # Gruppen gebildet
(3, "draw"), # Spiele ausgelost
(4, "finalized"), # Alle Ergebnisse da
)
```
5. **GameMixin für QGame verwenden**:
```python
from scheduler.models import GameMixin
class QGame(GameMixin):
# Erbt: homeGoals, awayGoals, resultEntered, season
qnode = models.ForeignKey(QNode, ...)
```
6. **Signals mit transaction.atomic()**:
```python
@receiver(post_save, sender=QGame)
def post_match_signal(sender, instance, created, **kwargs):
with transaction.atomic():
# Signal-Logik
```
## Beispiel: Neues Feld zu QNode
```python
# qualifiers/models.py
class QNode(models.Model):
# Existierende Felder...
# Neues Feld
auto_advance = models.BooleanField(
default=False,
help_text="Automatisch zum nächsten State wechseln"
)
```
## Beispiel: Neues Model
```python
# qualifiers/models.py
class QNodeViolation(models.Model):
"""Speichert Constraint-Verletzungen für einen Node."""
node = models.ForeignKey(
QNode,
on_delete=models.CASCADE,
related_name='violations',
db_index=True
)
scenario = models.ForeignKey(
'scheduler.Scenario',
on_delete=models.CASCADE,
db_index=True
)
type = models.CharField(max_length=50) # 'country_clash', 'distance', etc.
message = models.TextField()
severity = models.IntegerField(default=1) # 1=Warning, 2=Error
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['node', 'scenario'], name='qnodeviolation_node_scen_idx'),
]
```
## Migration erstellen
```bash
conda activate planner
python manage.py makemigrations qualifiers
python manage.py migrate qualifiers
```
## Referenz-Dateien
- [qualifiers/models.py](qualifiers/models.py) - Alle Models
- [docs/qualifiers/models/](docs/qualifiers/models/) - Dokumentation

View File

@ -0,0 +1,163 @@
# qualifiers-seeding
Automatisiert das Team-Seeding für UEFA-Qualifikationsturniere.
## Trigger
- Neue Saison erstellen
- Team-Import
- "seeding", "seed teams", "neue saison"
## Kontext
Das Seeding erstellt die Node-Struktur und weist Teams basierend auf UEFA-Koeffizienten zu. Die Hauptfunktion ist `seed_nodes25()` in [qualifiers/helpers.py](qualifiers/helpers.py).
## Regeln
1. **Node-Struktur folgt UEFA-Format**:
```
Champions Path:
└── UCL: Q1 → Q2 → Q3 → PO
└── UEL: Q2 → Q3 → PO
└── UECL: Q3 → PO
League Path:
└── UCL: Q2 → Q3 → PO
└── UEL: Q3 → PO
```
2. **Koeffizienten-Ranking**:
```python
# Formel: Koeffizient * 100000 - Position
# Höherer Wert = besseres Ranking
coefficients[team.id] = float(100000 * team.coefficient - team.position)
```
3. **State-Transitions**:
- `none` (0) → `seeded` (1): Nach `seeded_teams.set()`
- Vorgänger-Nodes müssen mindestens State `draw` (3) haben
4. **GroupsOfSize automatisch erstellen**:
```python
node.create_groupsofsize()
# Erstellt: 4, 6, 8, 10, 12, 20 mit number=0
```
## Beispiel: Nodes für neue Saison erstellen
```python
from qualifiers.models import QPath, QTier, QStage, QNode
# Path erstellen
cp = QPath.objects.create(scenario=scenario, name='Champions Path')
# Tiers erstellen
ucl = QTier.objects.create(
scenario=scenario,
path=cp,
name='UCL',
ypos=3,
optimize_prio=50
)
# Stages erstellen
q1 = QStage.objects.create(scenario=scenario, path=cp, name='Q1', xpos=1)
q2 = QStage.objects.create(scenario=scenario, path=cp, name='Q2', xpos=2)
# Nodes erstellen mit Navigation
node_q1 = QNode.objects.create(
scenario=scenario,
name='Q1',
tier=ucl,
stage=q1,
type=0, # Grouping
state=1, # Seeded
)
node_q2 = QNode.objects.create(
scenario=scenario,
name='Q2',
tier=ucl,
stage=q2,
type=0,
state=0, # Wartet auf Q1
)
# Navigation setzen
node_q1.winners = node_q2
node_q1.save()
```
## Beispiel: Teams seeden
```python
from scheduler.models import Team
from qualifiers.models import QNode
node = QNode.objects.get(
scenario=scenario,
tier__name='UCL',
stage__name='Q1'
)
# Top 16 Teams nach Koeffizient
teams = Team.objects.filter(
season=scenario.season,
active=True
).order_by('-coefficient')[:16]
# Teams zuweisen
node.seeded_teams.set(teams)
node.state = 1 # Seeded
node.save()
# Gruppengrößen erstellen
node.create_groupsofsize()
```
## Beispiel: Upcomers von Vorgänger-Node
```python
# Nach Q1 Draw: Teams für Q2 sammeln
q1_node = QNode.objects.get(scenario=scenario, tier__name='UCL', stage__name='Q1')
q2_node = QNode.objects.get(scenario=scenario, tier__name='UCL', stage__name='Q2')
# Upcomers sind finalisierte Matches aus Q1
upcomers = q2_node.upcomers(scenario)
# Format:
# [
# {'id': 123, 'team': <Team>, 'coeff': 45.5, 'countries': ['GER'], ...},
# {'id': 456, 'match': <QMatch>, 'coeff': 30.2, 'countries': ['ESP', 'ITA'], ...},
# ]
```
## Validierung
```python
def validate_node_seeding(node):
"""Prüft Seeding-Voraussetzungen."""
errors = []
# Mindestens 2 Teams
total_teams = node.nTeams(node.upcomers(node.scenario))
if total_teams < 2:
errors.append("Mindestens 2 Teams erforderlich")
# Gerade Anzahl
if total_teams % 2 != 0:
errors.append("Ungerade Teamanzahl")
# Alle Vorgänger finalisiert
for pred in node.qnode_winners.all() | node.qnode_losers.all():
if pred.current_state(node.scenario) < 3:
errors.append(f"Vorgänger {pred.which()} nicht im Draw-State")
return errors
```
## Referenz-Dateien
- [qualifiers/helpers.py](qualifiers/helpers.py) - seed_nodes25()
- [qualifiers/uefadigitalapi/seed_2025.py](qualifiers/uefadigitalapi/seed_2025.py)
- [docs/qualifiers/workflows/seeding.md](docs/qualifiers/workflows/seeding.md)

View File

@ -0,0 +1,138 @@
# qualifiers-solver
Erstellt und optimiert MIP-Modelle für die Gruppenbildung in UEFA-Qualifikationsturnieren.
## Trigger
- Neue Optimierungsregeln
- Solver-Constraints
- "grouping solver", "mip constraint", "optimization"
## Kontext
Der Solver verwendet PuLP mit Xpress/CBC Backend zur Gruppenbildung. Hauptfunktion ist `groupTeams()` in [qualifiers/solver/draws.py](qualifiers/solver/draws.py).
## Regeln
1. **Solver-Auswahl über Environment**:
```python
from leagues.settings import SOLVER
if SOLVER == 'xpress':
solver = XPRESS_PY(msg=1, mipgap=mipgap)
else:
solver = PULP_CBC_CMD(msg=1, mipGap=mipgap)
```
2. **Entscheidungsvariablen als Dict**:
```python
# Team t in Gruppe g
x = pulp.LpVariable.dicts(
"x",
[(t, g) for t in teams for g in groups],
cat='Binary'
)
```
3. **Constraints mit lpSum**:
```python
from pulp import lpSum
# Jedes Team genau einer Gruppe zuordnen
for t in teams:
model += lpSum(x[t, g] for g in groups) == 1
```
4. **Zielfunktion mit Gewichtung**:
```python
# priority: 0-100 (Distanz vs. Koeffizient)
coeff_weight = priority / 100
dist_weight = 1 - coeff_weight
model += coeff_weight * coeff_deviation + dist_weight * total_distance
```
5. **Infeasibility-Handling**:
```python
status = model.solve(solver)
if status != pulp.LpStatusOptimal:
# IIS (Irreducible Infeasible Set) analysieren
raise InfeasibleError(f"Solver status: {pulp.LpStatus[status]}")
```
## Beispiel: Neuer Constraint
```python
# qualifiers/solver/draws.py - in groupTeams()
# Beispiel: Maximal 2 Teams aus Top-10 Ländern pro Gruppe
top10_countries = ['ESP', 'ENG', 'GER', 'ITA', 'FRA', 'POR', 'NED', 'BEL', 'UKR', 'TUR']
for g in groups:
model += lpSum(
x[t, g]
for t in teams
if any(c in top10_countries for c in t_countries[t])
) <= 2, f"max_top10_in_group_{g}"
```
## Beispiel: Soft Constraint (Zielfunktion)
```python
# Strafe für ungleiche Gruppengrößen
size_penalty = pulp.LpVariable.dicts(
"size_penalty",
groups,
lowBound=0,
cat='Continuous'
)
avg_size = sum(groupsizes_all.values()) / len(groups)
for g in groups:
model += size_penalty[g] >= lpSum(x[t, g] for t in teams) - avg_size
model += size_penalty[g] >= avg_size - lpSum(x[t, g] for t in teams)
# Zur Zielfunktion addieren
model += ... + 0.1 * lpSum(size_penalty[g] for g in groups)
```
## Debugging
```python
# Verbose Output
model.solve(XPRESS_PY(msg=1))
# Variablenwerte ausgeben
for v in model.variables():
if v.varValue > 0.5:
print(f"{v.name} = {v.varValue}")
# Constraint-Slack prüfen
for name, constraint in model.constraints.items():
if constraint.slack < 0:
print(f"Verletzt: {name}, slack={constraint.slack}")
```
## Performance-Tipps
1. **Symmetrie-Breaking**: Erste Gruppe fixieren
```python
# Erstes Team immer in Gruppe 0
model += x[teams[0], 0] == 1
```
2. **MIP-Gap setzen**:
```python
solver = XPRESS_PY(mipgap=0.01) # 1% Toleranz
```
3. **Warm-Start mit fixed_groups**:
```python
for (team_id, group_id) in fixed_groups:
model += x[team_id, group_id] == 1
```
## Referenz-Dateien
- [qualifiers/solver/draws.py](qualifiers/solver/draws.py) - Solver-Code
- [docs/qualifiers/solver/](docs/qualifiers/solver/) - Dokumentation

View File

@ -0,0 +1,184 @@
# qualifiers-uefa-api
Integriert die UEFA Digital API für Team-Daten, Koeffizienten und Clashes.
## Trigger
- UEFA API-Aufrufe
- Team-Synchronisation
- Clash-Updates
- "uefa api", "team sync", "clashes aktualisieren"
## Kontext
Die UEFA Digital API verwendet OAuth2 Client Credentials Flow. API-Skripte befinden sich in [qualifiers/uefadigitalapi/](qualifiers/uefadigitalapi/).
## Regeln
1. **Umgebungsvariablen für Credentials**:
```bash
UEFA_OAUTH_TENANT_ID=<azure-tenant-id>
UEFA_OAUTH_CLIENT_ID=<client-id>
UEFA_OAUTH_CLIENT_SECRET=<client-secret>
UEFA_OAUTH_SCOPE=<api-scope>
UEFA_SUBSCRIPTION_KEY=<subscription-key>
UEFA_SUBSCRIPTION_KEY_TOKEN=<subscription-key-for-token>
```
2. **Token-Abruf**:
```python
import requests
import os
def get_uefa_token():
tenant_id = os.getenv("UEFA_OAUTH_TENANT_ID")
response = requests.post(
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "client_credentials",
"client_id": os.getenv("UEFA_OAUTH_CLIENT_ID"),
"client_secret": os.getenv("UEFA_OAUTH_CLIENT_SECRET"),
"scope": os.getenv("UEFA_OAUTH_SCOPE"),
},
)
return response.json()['access_token']
```
3. **API-Aufruf mit Bearer Token**:
```python
token = get_uefa_token()
headers = {
"Authorization": f"Bearer {token}",
"Ocp-Apim-Subscription-Key": os.getenv("UEFA_SUBSCRIPTION_KEY_TOKEN"),
}
response = requests.get(
"https://api.digital.uefa.com/comp/v2/...",
headers=headers,
timeout=30
)
```
4. **Retry-Logik bei Fehlern**:
```python
import time
def safe_api_call(url, headers, retries=3):
for attempt in range(retries):
try:
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 429: # Rate limit
retry_after = int(response.headers.get('Retry-After', 60))
time.sleep(retry_after)
continue
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
if attempt == retries - 1:
raise
time.sleep(2 ** attempt) # Exponential backoff
```
5. **Daten-Mapping**:
| UEFA Feld | Django Model | Feld |
|-----------|--------------|------|
| `teamId` | Team | `external_id` |
| `teamName` | Team | `name` |
| `countryCode` | Team.countryObj | `shortname` |
| `coefficient` | Team | `coefficient` |
| `position` | Team | `position` |
## Beispiel: Teams importieren
```python
import requests
from scheduler.models import Team, Country
def import_teams(scenario, competition_id):
token = get_uefa_token()
headers = {
"Authorization": f"Bearer {token}",
"Ocp-Apim-Subscription-Key": os.getenv("UEFA_SUBSCRIPTION_KEY_TOKEN"),
}
url = f"https://api.digital.uefa.com/comp/v2/competitions/{competition_id}/teams"
data = safe_api_call(url, headers)
for team_data in data['teams']:
country = Country.objects.get(shortname=team_data['countryCode'])
team, created = Team.objects.update_or_create(
season=scenario.season,
external_id=team_data['teamId'],
defaults={
'name': team_data['teamName'],
'countryObj': country,
'coefficient': team_data.get('coefficient', 0),
'position': team_data.get('position', 0),
'active': True,
}
)
if created:
print(f"Neues Team: {team.name}")
```
## Beispiel: Clashes aktualisieren
```python
from qualifiers.models import QClash
def update_clashes(scenario, season_code):
token = get_uefa_token()
headers = {...}
url = f"https://api.digital.uefa.com/comp/v2/seasons/{season_code}/clashes"
data = safe_api_call(url, headers)
for clash_data in data['clashes']:
team1 = Team.objects.get(
season=scenario.season,
external_id=clash_data['team1Id']
)
team2 = Team.objects.get(
season=scenario.season,
external_id=clash_data['team2Id']
)
QClash.objects.update_or_create(
scenario=scenario,
team1=team1,
team2=team2,
defaults={
'type': clash_data.get('type', 0),
'active_q1': True,
'active_q2': True,
'active_q3': True,
'active_po': True,
}
)
```
## Caching (geplant)
```python
from django.core.cache import cache
def get_cached_api_data(key, url, headers, timeout=3600):
"""Cached API-Aufruf."""
cached = cache.get(key)
if cached:
return cached
data = safe_api_call(url, headers)
cache.set(key, data, timeout)
return data
```
## Referenz-Dateien
- [qualifiers/uefadigitalapi/uefa_api_2526.py](qualifiers/uefadigitalapi/uefa_api_2526.py)
- [qualifiers/uefadigitalapi/seed_2025.py](qualifiers/uefadigitalapi/seed_2025.py)
- [qualifiers/uefadigitalapi/update_clashes.py](qualifiers/uefadigitalapi/update_clashes.py)
- [docs/qualifiers/api/uefa-oauth2.md](docs/qualifiers/api/uefa-oauth2.md)

View File

@ -0,0 +1,117 @@
---
name: translation-review
description: Überprüft Django .po Übersetzungsdateien auf Qualität, Kontext-Passgenauigkeit, Konsistenz und fehlende Übersetzungen. Verbessert Übersetzungen direkt. Für Translation-QA und Review.
argument-hint: "[locale] [--dry-run]"
allowed-tools: Glob, Grep, Read, Edit, Write
user-invocable: true
---
# Django Translation Review
Überprüft und verbessert Django `.po` Übersetzungsdateien im league-planner Projekt. Stellt sicher, dass Übersetzungen kontextgerecht, konsistent und vollständig sind.
## Argumente
- `locale` (optional): Bestimmte Sprache prüfen, z.B. `de`, `fr`, `ko`. Ohne Angabe → alle Locales.
- `--dry-run` (optional): Nur Bericht erstellen, keine Änderungen vornehmen.
## Workflow
### Phase 1: Discovery
1. Finde alle `.po` Files mit Glob: `**/locale/*/LC_MESSAGES/django.po`
2. Filtere nach dem angegebenen Locale (falls angegeben)
3. Überspringe `locale/common/` (das ist ein Referenz-File, kein echtes Locale)
4. Lies die `.po` Files und erstelle eine Übersicht: Locale, Anzahl Einträge, leere msgstr, fuzzy-Einträge
### Phase 2: Kontext-Analyse
Um kontextgerechte Übersetzungen sicherzustellen, lies diese Kontext-Quellen:
1. **`scheduler/choices.py`** — Enthält Sport-spezifische Choice-Tuples mit `gettext_lazy`. Hier ist der Fachkontext (Sport/Liga-Planung) am klarsten.
2. **Source-Referenzen in .po** — Jeder `msgid` hat `#:` Kommentare die zeigen, wo der String verwendet wird. Nutze diese um den Kontext zu verstehen:
- Strings aus `templates/` → UI-Labels, Buttons, Überschriften
- Strings aus `models.py` → Feld-Labels, verbose_names
- Strings aus `choices.py` → Dropdown-/Select-Optionen
- Strings aus `views.py` → Nachrichten, Fehlermeldungen
- Strings aus `helpers.py` → Solver-/System-Meldungen
### Phase 3: Qualitätsprüfung
Prüfe jede Übersetzung auf diese Kriterien:
#### Kritisch
- **Leere Übersetzungen**: `msgstr ""` wo eine Übersetzung erwartet wird
- **Fuzzy-Markierungen**: `#, fuzzy` Einträge die manuell bestätigt werden müssen
- **Falsche Platzhalter**: `%(name)s`, `{0}`, `%d` müssen exakt übereinstimmen
#### Kontext
- **Sport-Fachbegriffe**: Im Kontext von Liga-Planung die richtigen Begriffe verwenden:
- "Match" → "Spiel" (nicht "Übereinstimmung")
- "Day" (als Spieltag) → "Spieltag" (nicht "Tag")
- "Round" → "Runde" oder "Spieltag" je nach Kontext
- "Home/Away" → "Heim/Auswärts"
- "Fixture" → "Begegnung" oder "Ansetzung"
- "Draw" → "Auslosung" (nicht "Zeichnung")
- "Group" (im Turnier) → "Gruppe"
- "Seed" → "Setzliste"/"gesetzt" (nicht "Samen")
- "Clash" → "Überschneidung" oder "Konflikt"
- "Scenario" → "Szenario"
- "Constraint" → "Nebenbedingung" oder "Einschränkung"
- "Wish" → "Wunsch" (Planungswunsch)
- "Run" (Solver-Run) → "Lauf" oder "Durchlauf"
- "Slot" → "Zeitfenster" oder "Slot"
#### Konsistenz
- **Gleiche Begriffe gleich übersetzen**: Wenn "Scenario" einmal "Szenario" ist, muss es überall "Szenario" sein
- **Stil-Konsistenz**: Formell/Informell einheitlich (Du vs. Sie). league-planner verwendet **Du-Form**
- **Groß-/Kleinschreibung**: Deutsche Substantive groß, UI-Labels konsistent
#### Stil
- **Natürliche Sprache**: Übersetzungen sollen sich natürlich anfühlen, nicht wie maschinelle Übersetzung
- **Kürze**: UI-Labels kurz halten, keine unnötigen Wörter
- **Aktive Sprache**: "Szenario wurde erstellt" statt "Es wurde ein Szenario erstellt"
### Phase 4: Änderungen anwenden
Wenn NICHT `--dry-run`:
1. Für jede gefundene Verbesserung, nutze das Edit-Tool um den `msgstr` zu aktualisieren
2. Entferne `#, fuzzy` Markierungen wenn die Übersetzung korrekt bestätigt wurde
3. Fülle leere `msgstr` aus, sofern der Kontext eine sichere Übersetzung erlaubt
4. **WICHTIG**: Ändere NIEMALS `msgid` — nur `msgstr` darf geändert werden
5. **WICHTIG**: Erhalte alle `#:` Source-Referenz-Kommentare unverändert
6. **WICHTIG**: Erhalte Python-Format-Strings exakt (`%(name)s`, `{0}`, `%d` etc.)
### Phase 5: Zusammenfassung
Gib am Ende eine strukturierte Zusammenfassung:
```
## Translation Review — [Locale]
### Statistik
- Geprüfte Einträge: X
- Verbesserungen: Y
- Fehlende Übersetzungen (gefüllt): Z
- Fuzzy aufgelöst: W
- Unverändert: V
### Änderungen
| # | msgid | Alt | Neu | Grund |
|---|-------|-----|-----|-------|
| 1 | "..." | "..." | "..." | Kontext/Konsistenz/Stil |
### Offene Punkte
- [Einträge die manuelle Prüfung brauchen]
```
## Wichtige Regeln
- **Konservativ sein**: Im Zweifel lieber nicht ändern als eine falsche Übersetzung einführen
- **Kontext prüfen**: Immer die `#:` Source-Referenzen lesen bevor eine Übersetzung geändert wird
- **Format-Strings erhalten**: `%(count)d Spiele` nicht `%(anzahl)d Spiele`
- **Plural-Forms beachten**: `msgid_plural` / `msgstr[0]` / `msgstr[1]` korrekt handhaben
- **Kein Overengineering**: Wenn eine Übersetzung gut genug ist, nicht ändern nur um sie "schöner" zu machen
- **Locale `en`**: Englische .po Files haben typischerweise leere msgstr — das ist korrekt, da `msgid` bereits Englisch ist. Diese NICHT ausfüllen.
- **Locale `common`**: Ist ein Referenz-File, nicht bearbeiten