395 lines
11 KiB
Markdown
395 lines
11 KiB
Markdown
---
|
|
name: lp-django-model
|
|
description: Creates Django 6 models for league-planner with Fat Model pattern, custom managers, Meta indexes/constraints, and query optimization. Use when adding new models.
|
|
argument-hint: <model-name> [fields...]
|
|
allowed-tools: Read, Write, Edit, Glob, Grep
|
|
---
|
|
|
|
# League-Planner Django Model Generator
|
|
|
|
Creates production-ready Django 6 models following the league-planner project patterns with Fat Models, custom managers, proper Meta configuration, and query optimization hints.
|
|
|
|
## When to Use
|
|
|
|
- Creating new models for scheduler, draws, qualifiers, or other apps
|
|
- Adding fields or relationships to existing models
|
|
- Implementing custom managers or querysets
|
|
- Setting up model indexes and constraints
|
|
|
|
## Prerequisites
|
|
|
|
- Model should be placed in the appropriate app's `models.py`
|
|
- Related models should already exist or be created together
|
|
- Consider the model hierarchy: League → Season → Scenario → Match
|
|
|
|
## Instructions
|
|
|
|
### Step 1: Analyze Requirements
|
|
|
|
Before generating the model:
|
|
1. Identify the app where the model belongs (scheduler, draws, qualifiers, common, api)
|
|
2. Determine relationships to existing models
|
|
3. List required fields with types and constraints
|
|
4. Identify query patterns for index optimization
|
|
|
|
### Step 2: Generate Model Structure
|
|
|
|
Follow this template based on project patterns:
|
|
|
|
```python
|
|
from django.db import models
|
|
from django.db.models import Manager, QuerySet
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
|
class MyModelQuerySet(QuerySet):
|
|
"""Custom QuerySet with chainable methods."""
|
|
|
|
def active(self):
|
|
return self.filter(is_active=True)
|
|
|
|
def for_season(self, season):
|
|
return self.filter(season=season)
|
|
|
|
|
|
class MyModelManager(Manager):
|
|
"""Custom manager using the QuerySet."""
|
|
|
|
def get_queryset(self) -> QuerySet:
|
|
return MyModelQuerySet(self.model, using=self._db)
|
|
|
|
def active(self):
|
|
return self.get_queryset().active()
|
|
|
|
|
|
class MyModel(models.Model):
|
|
"""
|
|
Brief description of the model's purpose.
|
|
|
|
Part of the hierarchy: Parent → ThisModel → Child
|
|
"""
|
|
|
|
# Foreign Keys (always first)
|
|
season = models.ForeignKey(
|
|
'scheduler.Season',
|
|
on_delete=models.CASCADE,
|
|
related_name='mymodels',
|
|
verbose_name=_('Season'),
|
|
)
|
|
|
|
# Required fields
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_('Name'),
|
|
help_text=_('Unique name within the season'),
|
|
)
|
|
|
|
# Optional fields with defaults
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
db_index=True,
|
|
verbose_name=_('Active'),
|
|
)
|
|
|
|
# Timestamps (project convention)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# Managers
|
|
objects = MyModelManager()
|
|
|
|
class Meta:
|
|
verbose_name = _('My Model')
|
|
verbose_name_plural = _('My Models')
|
|
ordering = ['-created_at']
|
|
|
|
# Indexes for common query patterns
|
|
indexes = [
|
|
models.Index(fields=['season', 'is_active']),
|
|
models.Index(fields=['name']),
|
|
]
|
|
|
|
# Constraints for data integrity
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['season', 'name'],
|
|
name='unique_mymodel_name_per_season'
|
|
),
|
|
models.CheckConstraint(
|
|
check=models.Q(name__isnull=False),
|
|
name='mymodel_name_not_null'
|
|
),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.name} ({self.season})"
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<MyModel(id={self.pk}, name='{self.name}')>"
|
|
|
|
# Business logic methods (Fat Model pattern)
|
|
def activate(self) -> None:
|
|
"""Activate this model instance."""
|
|
self.is_active = True
|
|
self.save(update_fields=['is_active', 'updated_at'])
|
|
|
|
def deactivate(self) -> None:
|
|
"""Deactivate this model instance."""
|
|
self.is_active = False
|
|
self.save(update_fields=['is_active', 'updated_at'])
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
"""Computed property for display purposes."""
|
|
return f"{self.name} - {self.season.name}"
|
|
|
|
# Query optimization hints
|
|
@classmethod
|
|
def get_with_related(cls, pk: int):
|
|
"""Fetch with all related objects pre-loaded."""
|
|
return cls.objects.select_related(
|
|
'season',
|
|
'season__league',
|
|
).prefetch_related(
|
|
'children',
|
|
).get(pk=pk)
|
|
```
|
|
|
|
### Step 3: Add Admin Registration
|
|
|
|
Create or update admin configuration in `admin.py`:
|
|
|
|
```python
|
|
from django.contrib import admin
|
|
from .models import MyModel
|
|
|
|
|
|
@admin.register(MyModel)
|
|
class MyModelAdmin(admin.ModelAdmin):
|
|
list_display = ['name', 'season', 'is_active', 'created_at']
|
|
list_filter = ['is_active', 'season__league']
|
|
search_fields = ['name', 'season__name']
|
|
raw_id_fields = ['season']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
|
|
def get_queryset(self, request):
|
|
return super().get_queryset(request).select_related('season', 'season__league')
|
|
```
|
|
|
|
## Patterns & Best Practices
|
|
|
|
### Field Naming Conventions
|
|
|
|
| Type | Convention | Example |
|
|
|------|------------|---------|
|
|
| Boolean | `is_*` or `has_*` | `is_active`, `has_permission` |
|
|
| Foreign Key | Singular noun | `season`, `team`, `user` |
|
|
| M2M | Plural noun | `teams`, `users`, `matches` |
|
|
| DateTime | `*_at` | `created_at`, `published_at` |
|
|
| Date | `*_date` | `start_date`, `end_date` |
|
|
|
|
### Relationship Patterns
|
|
|
|
```python
|
|
# One-to-Many (ForeignKey)
|
|
season = models.ForeignKey(
|
|
'scheduler.Season',
|
|
on_delete=models.CASCADE, # Delete children with parent
|
|
related_name='scenarios', # season.scenarios.all()
|
|
)
|
|
|
|
# Many-to-Many with through model
|
|
teams = models.ManyToManyField(
|
|
'scheduler.Team',
|
|
through='TeamInGroup',
|
|
related_name='groups',
|
|
)
|
|
|
|
# Self-referential
|
|
parent = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='children',
|
|
)
|
|
```
|
|
|
|
### Index Strategies
|
|
|
|
```python
|
|
class Meta:
|
|
indexes = [
|
|
# Composite index for filtered queries
|
|
models.Index(fields=['season', 'status', '-created_at']),
|
|
|
|
# Partial index
|
|
models.Index(
|
|
fields=['name'],
|
|
condition=models.Q(is_active=True),
|
|
name='idx_active_names'
|
|
),
|
|
|
|
# Covering index for read-heavy queries
|
|
models.Index(
|
|
fields=['season', 'team'],
|
|
include=['score', 'status'],
|
|
name='idx_match_lookup'
|
|
),
|
|
]
|
|
```
|
|
|
|
### Django 6 Features
|
|
|
|
```python
|
|
# Composite Primary Keys
|
|
from django.db.models import CompositePrimaryKey
|
|
|
|
class TeamMatch(models.Model):
|
|
team = models.ForeignKey(Team, on_delete=models.CASCADE)
|
|
match = models.ForeignKey(Match, on_delete=models.CASCADE)
|
|
|
|
class Meta:
|
|
pk = CompositePrimaryKey('team', 'match')
|
|
|
|
# GeneratedField for computed columns
|
|
from django.db.models import GeneratedField, F, Value
|
|
from django.db.models.functions import Concat
|
|
|
|
class Player(models.Model):
|
|
first_name = models.CharField(max_length=100)
|
|
last_name = models.CharField(max_length=100)
|
|
full_name = GeneratedField(
|
|
expression=Concat(F('first_name'), Value(' '), F('last_name')),
|
|
output_field=models.CharField(max_length=201),
|
|
db_persist=True,
|
|
)
|
|
```
|
|
|
|
## Examples
|
|
|
|
### Example 1: Match Model (Scheduler App)
|
|
|
|
```python
|
|
class Match(models.Model):
|
|
"""A single game between two teams in a scenario."""
|
|
|
|
scenario = models.ForeignKey(
|
|
'Scenario',
|
|
on_delete=models.CASCADE,
|
|
related_name='matches',
|
|
)
|
|
home_team = models.ForeignKey(
|
|
'Team',
|
|
on_delete=models.CASCADE,
|
|
related_name='home_matches',
|
|
)
|
|
away_team = models.ForeignKey(
|
|
'Team',
|
|
on_delete=models.CASCADE,
|
|
related_name='away_matches',
|
|
)
|
|
day = models.ForeignKey(
|
|
'Day',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='matches',
|
|
)
|
|
kick_off_time = models.ForeignKey(
|
|
'KickOffTime',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
|
|
is_final = models.BooleanField(default=False, db_index=True)
|
|
is_confirmed = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
verbose_name = _('Match')
|
|
verbose_name_plural = _('Matches')
|
|
ordering = ['day__number', 'kick_off_time__time']
|
|
indexes = [
|
|
models.Index(fields=['scenario', 'day']),
|
|
models.Index(fields=['home_team', 'away_team']),
|
|
]
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
check=~models.Q(home_team=models.F('away_team')),
|
|
name='match_different_teams'
|
|
),
|
|
]
|
|
|
|
@classmethod
|
|
def get_for_scenario(cls, scenario_id: int):
|
|
"""Optimized query for listing matches."""
|
|
return cls.objects.filter(
|
|
scenario_id=scenario_id
|
|
).select_related(
|
|
'home_team',
|
|
'away_team',
|
|
'day',
|
|
'kick_off_time',
|
|
).order_by('day__number', 'kick_off_time__time')
|
|
```
|
|
|
|
### Example 2: Group Model (Draws App)
|
|
|
|
```python
|
|
class Group(models.Model):
|
|
"""A group within a tournament draw."""
|
|
|
|
super_group = models.ForeignKey(
|
|
'SuperGroup',
|
|
on_delete=models.CASCADE,
|
|
related_name='groups',
|
|
)
|
|
name = models.CharField(max_length=50)
|
|
position = models.PositiveSmallIntegerField(default=0)
|
|
|
|
teams = models.ManyToManyField(
|
|
'scheduler.Team',
|
|
through='TeamInGroup',
|
|
related_name='draw_groups',
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ['super_group', 'position']
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=['super_group', 'name'],
|
|
name='unique_group_name_in_supergroup'
|
|
),
|
|
]
|
|
|
|
@property
|
|
def team_count(self) -> int:
|
|
return self.teams.count()
|
|
|
|
def get_teams_with_country(self):
|
|
"""Optimized team fetch with country data."""
|
|
return self.teams.select_related('country').order_by('name')
|
|
```
|
|
|
|
## Common Pitfalls
|
|
|
|
- **Missing `related_name`**: Always specify `related_name` for ForeignKey and M2M fields to enable reverse lookups
|
|
- **No indexes on filter fields**: Add indexes for fields frequently used in `filter()` or `order_by()`
|
|
- **N+1 queries**: Use `select_related()` for FK/O2O and `prefetch_related()` for M2M/reverse FK
|
|
- **Forgetting `db_index=True`**: Boolean fields used in filters need explicit indexing
|
|
- **Overly broad `on_delete=CASCADE`**: Consider `PROTECT` or `SET_NULL` for important references
|
|
|
|
## Verification
|
|
|
|
After creating a model:
|
|
|
|
1. Run `python manage.py makemigrations` to generate migrations
|
|
2. Review the generated migration for correctness
|
|
3. Test with `python manage.py shell`:
|
|
```python
|
|
from scheduler.models import MyModel
|
|
# Model should be auto-imported in Django 6 shell
|
|
MyModel.objects.create(name='Test', season=season)
|
|
```
|