En ce qui concerne la façon de configurer les modèles, vous avez raison de dire qu'une table traversante avec une colonne "order" est le moyen idéal pour la représenter. Vous avez également raison de dire que Django ne vous laissera pas faire référence à cette relation dans un fieldset. L'astuce pour résoudre ce problème est de se rappeler que les noms de champs que vous spécifiez dans les champs d'un ModelAdmin
ne se réfèrent pas aux champs du Model
, mais aux champs du Model
, que nous sommes libres passer outre à la joie de notre coeur. Avec beaucoup de nombreux champs, cela devient difficile, mais supporter avec moi:
Disons que vous essayez de représenter les concours et les concurrents qui y concourent, avec un nombre de2many ordonné entre les concours et les concurrents où la commande représente le classement des concurrents dans ce concours. Votre models.py
doit ressembler à ceci:
from django.db import models
class Contest(models.Model):
name = models.CharField(max_length=50)
# More fields here, if you like.
contestants = models.ManyToManyField('Contestant', through='ContestResults')
class Contestant(models.Model):
name = models.CharField(max_length=50)
class ContestResults(models.Model):
contest = models.ForeignKey(Contest)
contestant = models.ForeignKey(Contestant)
rank = models.IntegerField()
Si tout va bien, ce qui est similaire à ce que vous avez affaire. Maintenant, pour l'administrateur. J'ai écrit un exemple admin.py
avec beaucoup de commentaires pour expliquer ce qui se passe, mais voici un résumé pour vous aider:
Comme je n'ai pas le code pour le widget m2m ordonné que vous avez écrit, j'ai utilisé un widget fictif d'espace réservé qui hérite simplement de TextInput
. L'entrée contient une liste séparée par des virgules (sans espaces) des identifiants des concurrents, et l'ordre de leur apparition dans la chaîne détermine la valeur de leur colonne "rank" dans le modèle ContestResults
. Qu'est-ce qui se passe est que nous remplaçons le ModelForm
par défaut pour le concours avec le notre, puis définissons un champ "résultats" à l'intérieur (nous ne pouvons pas appeler le champ "candidats", car il y aurait un conflit de nom avec le champ m2m dans le modèle). Nous remplaçons ensuite __init__()
, qui est appelée lorsque le formulaire est affiché dans l'admin, afin que nous puissions récupérer tous les ContestResults qui ont déjà été définis pour le Contest et les utiliser pour remplir le widget. Nous remplaçons également save()
, afin que nous puissions à leur tour récupérer les données du widget et créer les ContestResults nécessaires. Notez que par souci de simplicité cet exemple omet des choses comme la validation des données du widget, donc les choses vont se casser si vous essayez de taper quelque chose d'inattendu dans la saisie de texte. En outre, le code pour créer ContestResults est assez simpliste et pourrait être grandement amélioré.
Je devrais également ajouter que j'ai effectivement couru ce code et vérifié que cela fonctionne.
from django import forms
from django.contrib import admin
from models import Contest, Contestant, ContestResults
# Generates a function that sequentially calls the two functions that were
# passed to it
def func_concat(old_func, new_func):
def function():
old_func()
new_func()
return function
# A dummy widget to be replaced with your own.
class OrderedManyToManyWidget(forms.widgets.TextInput):
pass
# A simple CharField that shows a comma-separated list of contestant IDs.
class ResultsField(forms.CharField):
widget = OrderedManyToManyWidget()
class ContestAdminForm(forms.models.ModelForm):
# Any fields declared here can be referred to in the "fieldsets" or
# "fields" of the ModelAdmin. It is crucial that our custom field does not
# use the same name as the m2m field field in the model ("contestants" in
# our example).
results = ResultsField()
# Be sure to specify your model here.
class Meta:
model = Contest
# Override init so we can populate the form field with the existing data.
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance', None)
# See if we are editing an existing Contest. If not, there is nothing
# to be done.
if instance and instance.pk:
# Get a list of all the IDs of the contestants already specified
# for this contest.
contestants = ContestResults.objects.filter(contest=instance).order_by('rank').values_list('contestant_id', flat=True)
# Make them into a comma-separated string, and put them in our
# custom field.
self.base_fields['results'].initial = ','.join(map(str, contestants))
# Depending on how you've written your widget, you can pass things
# like a list of available contestants to it here, if necessary.
super(ContestAdminForm, self).__init__(*args, **kwargs)
def save(self, *args, **kwargs):
# This "commit" business complicates things somewhat. When true, it
# means that the model instance will actually be saved and all is
# good. When false, save() returns an unsaved instance of the model.
# When save() calls are made by the Django admin, commit is pretty
# much invariably false, though I'm not sure why. This is a problem
# because when creating a new Contest instance, it needs to have been
# saved in the DB and have a PK, before we can create ContestResults.
# Fortunately, all models have a built-in method called save_m2m()
# which will always be executed after save(), and we can append our
# ContestResults-creating code to the existing same_m2m() method.
commit = kwargs.get('commit', True)
# Save the Contest and get an instance of the saved model
instance = super(ContestAdminForm, self).save(*args, **kwargs)
# This is known as a lexical closure, which means that if we store
# this function and execute it later on, it will execute in the same
# context (i.e. it will have access to the current instance and self).
def save_m2m():
# This is really naive code and should be improved upon,
# especially in terms of validation, but the basic gist is to make
# the needed ContestResults. For now, we'll just delete any
# existing ContestResults for this Contest and create them anew.
ContestResults.objects.filter(contest=instance).delete()
# Make a list of (rank, contestant ID) tuples from the comma-
# -separated list of contestant IDs we get from the results field.
formdata = enumerate(map(int, self.cleaned_data['results'].split(',')), 1)
for rank, contestant in formdata:
ContestResults.objects.create(contest=instance, contestant_id=contestant, rank=rank)
if commit:
# If we're committing (fat chance), simply run the closure.
save_m2m()
else:
# Using a function concatenator, ensure our save_m2m closure is
# called after the existing save_m2m function (which will be
# called later on if commit is False).
self.save_m2m = func_concat(self.save_m2m, save_m2m)
# Return the instance like a good save() method.
return instance
class ContestAdmin(admin.ModelAdmin):
# The precious fieldsets.
fieldsets = (
('Basic Info', {
'fields': ('name', 'results',)
}),)
# Here's where we override our form
form = ContestAdminForm
admin.site.register(Contest, ContestAdmin)
Si vous vous demandez, je l'avais rencontré ce problème moi-même sur un projet que je travaille sur, donc la plupart de ce code provient de ce projet. J'espère que tu trouves cela utile.
+1 pour utiliser le mot 'idiosyncrasies' dans votre question. Idée cool. Je réfléchis. –
Pouvez-vous nous dire pourquoi vous utilisez un modèle 'through' au lieu d'un stock de plusieurs à plusieurs? quelles métadonnées supplémentaires stockez-vous dans cette relation? Le problème que je vois avec l'implémentation de cette fonctionnalité dans l'admin par défaut est qu'il n'y a actuellement aucun moyen d'ajouter les métadonnées supplémentaires dans la relation through simplement en choisissant les éléments d'une liste comme le suggère votre widget. – Thomas
Thomas: les données de commande seraient le champ additionnel, donnant le champ "through" trois champs: un ForeignKey de chaque côté du M2M et un ordre représentant PositiveIntegerField, avec les trois champs étant uniques_together. –