From 334dd8799449921b1bb4b6d5e4adf8b9770f4ee7 Mon Sep 17 00:00:00 2001 From: martin Date: Fri, 20 Feb 2026 22:08:04 +0100 Subject: [PATCH] Skills --- skills/django-templates/SKILL.md | 507 ++++++++++++++++++++++++++++ skills/qualifiers-model/SKILL.md | 123 +++++++ skills/qualifiers-seeding/SKILL.md | 163 +++++++++ skills/qualifiers-solver/SKILL.md | 138 ++++++++ skills/qualifiers-uefa-api/SKILL.md | 184 ++++++++++ skills/translation-review/SKILL.md | 117 +++++++ 6 files changed, 1232 insertions(+) create mode 100644 skills/django-templates/SKILL.md create mode 100644 skills/qualifiers-model/SKILL.md create mode 100644 skills/qualifiers-seeding/SKILL.md create mode 100644 skills/qualifiers-solver/SKILL.md create mode 100644 skills/qualifiers-uefa-api/SKILL.md create mode 100644 skills/translation-review/SKILL.md diff --git a/skills/django-templates/SKILL.md b/skills/django-templates/SKILL.md new file mode 100644 index 0000000..6763242 --- /dev/null +++ b/skills/django-templates/SKILL.md @@ -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 %} +
+

{{ title }}

+

{{ content }}

+
+{% endpartialdef %} + +{# Render the partial multiple times #} +{% partial card %} +{% partial card %} +``` + +**Inline Option** - Render immediately AND save for reuse: + +```django +{% partialdef filter_controls inline %} +
+ {{ filter_form.as_p }} + +
+{% 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 %} +
+ {{ item.name }} + ({{ forloop.counter }}/{{ forloop.length }}) +
+{% endfor %} +``` + +### querystring Tag Improvements + +Build query strings cleanly: + +```django +{# Basic usage #} +First Page + +{# Modify current query params #} +Next + +{# Remove a parameter #} +Clear Filter + +{# Multiple mappings (NEW in 6.0) #} +Reset +``` + +### CSP Nonce Support + +For inline scripts with Content Security Policy: + +```django +{# In settings: add 'django.template.context_processors.csp' #} + + +``` + +--- + +## Patterns & Best Practices + +### Pattern 1: Base Template with Blocks + +```django +{# templates/base.html #} +{% load static %} + + + + + + {% block title %}League Planner{% endblock %} + + {% block extra_css %}{% endblock %} + + + {% block navbar %} + {% include "includes/navbar.html" %} + {% endblock %} + +
+ {% block messages %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endblock %} + + {% block content %}{% endblock %} +
+ + {% block footer %} + {% include "includes/footer.html" %} + {% endblock %} + + {% block extra_js %}{% endblock %} + + +``` + +### Pattern 2: List Template with Partials + +```django +{# templates/scheduler/scenario_list.html #} +{% extends "base.html" %} + +{% block title %}Szenarien{% endblock %} + +{% block content %} +
+

Szenarien

+ + {# Define row partial for HTMX updates #} + {% partialdef scenario_row %} + + {{ scenario.name }} + {{ scenario.season }} + {{ scenario.get_status_display }} + + Details + + + + {% endpartialdef %} + + + + + + + + + + + + {% for scenario in scenarios %} + {% partial scenario_row %} + {% empty %} + + {% endfor %} + +
NameSaisonStatusAktionen
Keine Szenarien vorhanden.
+
+{% endblock %} +``` + +### Pattern 3: HTMX-Powered Form + +```django +{# templates/scheduler/scenario_form.html #} +{% extends "base.html" %} + +{% block content %} +
+

{% if scenario.pk %}Szenario bearbeiten{% else %}Neues Szenario{% endif %}

+ + {# Form partial for HTMX validation #} + {% partialdef scenario_form inline %} +
+ {% csrf_token %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} + {{ field.errors.0 }} + {% endif %} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
+ {% endfor %} + +
+ + Abbrechen +
+
+ {% endpartialdef %} +
+{% endblock %} +``` + +### Pattern 4: Detail Page with Inline Editing + +```django +{# templates/scheduler/scenario_detail.html #} +{% extends "base.html" %} + +{% block content %} +
+ {# Header partial - editable via HTMX #} + {% partialdef scenario_header inline %} +
+

{{ scenario.name }}

+

+ Saison: {{ scenario.season }} | + Status: {{ scenario.get_status_display }} | + Erstellt: {{ scenario.created_at|date:"d.m.Y H:i" }} +

+ +
+ {% endpartialdef %} + + {# Games list partial #} + {% partialdef games_list %} +
+

Spiele ({{ games|length }}/{{ games|length }})

+
    + {% for game in games %} +
  • + {{ game.home_team }} vs {{ game.away_team }} + {{ game.date|date:"d.m.Y" }} +
  • + {% endfor %} +
+
+ {% endpartialdef %} + + {% partial games_list %} +
+{% endblock %} +``` + +### Pattern 5: Modal with Partial + +```django +{# templates/includes/modal.html #} +{% partialdef modal_container %} + +{% endpartialdef %} +``` + +### Pattern 6: Pagination with querystring + +```django +{# templates/includes/pagination.html #} +{% if page_obj.has_other_pages %} + +{% 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}) +``` diff --git a/skills/qualifiers-model/SKILL.md b/skills/qualifiers-model/SKILL.md new file mode 100644 index 0000000..47825ac --- /dev/null +++ b/skills/qualifiers-model/SKILL.md @@ -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 diff --git a/skills/qualifiers-seeding/SKILL.md b/skills/qualifiers-seeding/SKILL.md new file mode 100644 index 0000000..cfd929a --- /dev/null +++ b/skills/qualifiers-seeding/SKILL.md @@ -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': , 'coeff': 45.5, 'countries': ['GER'], ...}, +# {'id': 456, 'match': , '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) diff --git a/skills/qualifiers-solver/SKILL.md b/skills/qualifiers-solver/SKILL.md new file mode 100644 index 0000000..06b0a24 --- /dev/null +++ b/skills/qualifiers-solver/SKILL.md @@ -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 diff --git a/skills/qualifiers-uefa-api/SKILL.md b/skills/qualifiers-uefa-api/SKILL.md new file mode 100644 index 0000000..f29df4d --- /dev/null +++ b/skills/qualifiers-uefa-api/SKILL.md @@ -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= + UEFA_OAUTH_CLIENT_ID= + UEFA_OAUTH_CLIENT_SECRET= + UEFA_OAUTH_SCOPE= + UEFA_SUBSCRIPTION_KEY= + UEFA_SUBSCRIPTION_KEY_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) diff --git a/skills/translation-review/SKILL.md b/skills/translation-review/SKILL.md new file mode 100644 index 0000000..129b4b9 --- /dev/null +++ b/skills/translation-review/SKILL.md @@ -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