Skills
This commit is contained in:
parent
3ffeec1344
commit
334dd87994
507
skills/django-templates/SKILL.md
Normal file
507
skills/django-templates/SKILL.md
Normal 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">×</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})
|
||||||
|
```
|
||||||
123
skills/qualifiers-model/SKILL.md
Normal file
123
skills/qualifiers-model/SKILL.md
Normal 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
|
||||||
163
skills/qualifiers-seeding/SKILL.md
Normal file
163
skills/qualifiers-seeding/SKILL.md
Normal 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)
|
||||||
138
skills/qualifiers-solver/SKILL.md
Normal file
138
skills/qualifiers-solver/SKILL.md
Normal 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
|
||||||
184
skills/qualifiers-uefa-api/SKILL.md
Normal file
184
skills/qualifiers-uefa-api/SKILL.md
Normal 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)
|
||||||
117
skills/translation-review/SKILL.md
Normal file
117
skills/translation-review/SKILL.md
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user