title: Django Many to Many and admin
body: <p>I have a django ap which has a rather complicated model setup. I ended up using multi level composition to create a hierarchical model. All the relations are one to one, so I could have use inheritance but I chose not to so that i would benefit from having object composition for my models, this means I can do things like</p>
  
  <pre><code>product.outerframe.top.cost
  </code></pre>
  
  <p>which make the complicated calculations I have to preform, a lot better organised.</p>
  
  <p>However, This model arrangement makes using the django admin tricky. I basically have a through table, i.e the outerframe table is just a bunch of foreign keys to other tables (with unique constraint on each). I ended up oerriding the add_view() and change_view() methods of ModelAdmin, which is pretty hard.</p>
  
  <p>Is there an easier way to deal with many to many / through tables when using the django admin?</p>
  
  <p><img src="https://i.stack.imgur.com/ur6WQ.jpg" alt="enter image description here"></p>
  
  <p>The tables are arranged like so:</p>
  
  <p>Product > outerframe, innerframe, glass, other</p>
  
  <p>outerframe > top, bottom, side etc.</p>
  
  <p>innerframe > top, bottom, side etc.</p>
  
  <p>glass > glass_type etc.</p>
  
  <p>other > accessories etc.</p>
  
  <p>Here are my models:</p>
  
  <pre><code>class Product(mixins.ProductVariables):
      name = models.CharField(max_length=255)
      sku = models.CharField(max_length=100, unique=True, db_index=True)
      image = thumbnail.ImageField(upload_to='product_images', blank=True)
      description = models.TextField(blank=True)
      group = models.ForeignKey('ProductGroup', related_name='products', null=True)
      hidden = models.BooleanField(default=False)
      product_specific_mark_up = models.DecimalField(default=1.0, max_digits=5,decimal_places=2)
  
      # Methods for totals
      def total_material_cost(self, width, height, options):
          return sum([
              self.outerframe.cost(width, height, options),
              self.innerframe.cost(width, height, options),
              self.glass.cost(width, height, options),
              self.other.cost(width, height, options),
          ])
  
      def total_labour_time(self, width, height, options):
          return sum([
              self.outerframe.labour_time(width, height, options),
              self.innerframe.labour_time(width, height, options),
              self.glass.labour_time(width, height, options),
              self.other.labour_time(width, height, options),
          ])
  
      def total_co2_output(self, width, height, options):
          return sum([
              self.outerframe.co2_output(width, height, options),
              self.innerframe.co2_output(width, height, options),
              self.glass.co2_output(width, height, options),
              self.other.co2_output(width, height, options),
          ])
  
      @property
      def max_overall_width(self):
          return 1000
  
      @property
      def max_overall_height(self):
          return 1000
  
      def __unicode__(self):
          return self.name
  
  
  class OuterFrame(models.Model, mixins.GetFieldsMixin, mixins.GetRelatedClassesMixin):
      top = models.OneToOneField(mixins.TopFrame)
      bottom = models.OneToOneField(mixins.BottomFrame)
      side = models.OneToOneField(mixins.SideFrame)
      accessories = models.OneToOneField(mixins.Accessories)
      flashing = models.OneToOneField(mixins.Flashing)
      silicone = models.OneToOneField(mixins.Silicone)
  
      product = models.OneToOneField(Product)
  
      def cost(self, width, height, options):
          #accessories_cost = (self.accessories.cost if options['accessories'] else 0)
          #flashing_cost = (self.flashing.cost if options['flashing'] else 0)
          #silicone_cost = (self.silicone.cost if options['silicone'] else 0)
          return sum([
              self.top.cost * (width / 1000),
              self.bottom.cost * (width / 1000),
              self.side.cost * (width*2 / 1000),
              #accessories_cost,
              #flashing_cost,
              #silicone_cost,
          ])
  
      def labour_time(self, width, height, options):
          return datetime.timedelta(minutes=100)
  
      def CO2_output(self, width, height, options):
          return 100 # some kg measurement
  
      @classmethod
      def get_fields(cls):
          options = cls._meta
          fields = {}
          for field in options.fields:
              if field.name == 'product':
                  continue
              if isinstance(field, models.OneToOneField):
                  related_cls = field.rel.to
                  related_fields = fields_for_model(related_cls, fields=related_cls.get_fields())
                  fields.update( { related_cls.__name__ + '_' + name:field for name, field in related_fields.iteritems() })
          return fields
  
  
  
  class InnerFrame(models.Model, mixins.GetFieldsMixin, mixins.GetRelatedClassesMixin):
      top = models.OneToOneField(mixins.TopFrame)
      bottom = models.OneToOneField(mixins.BottomFrame)
      side = models.OneToOneField(mixins.SideFrame)
      accessories = models.OneToOneField(mixins.Accessories)
  
      product = models.OneToOneField(Product)
  
      def cost(self, width, height, options):
          #accessories_cost = (self.accessories.cost if options['accessories'] else 0)
          print self.top.cost
          return sum([
              self.top.cost * (width / 1000),
              self.bottom.cost * (width / 1000),
              self.side.cost * (width*2 / 1000),
          #    accessories_cost,
          ])
  
      def labour_time(self, width, height, options):
          return datetime.timedelta(minutes=100)
  
      def CO2_output(self, width, height, options):
          return 100 # some kg measurement
  
  class Glass(models.Model, mixins.GetRelatedClassesMixin):
      glass_type_a = models.OneToOneField(mixins.GlassTypeA)
      glass_type_b = models.OneToOneField(mixins.GlassTypeB)
      enhanced = models.OneToOneField(mixins.Enhanced)
      laminate = models.OneToOneField(mixins.Laminate)
      low_iron = models.OneToOneField(mixins.LowIron)
      privacy = models.OneToOneField(mixins.Privacy)
      anti_slip = models.OneToOneField(mixins.AntiSlip)
      heat_film_mirror = models.OneToOneField(mixins.HeatMirrorField)
      posished_edges = models.OneToOneField(mixins.PolishedEdges)
  
      product = models.OneToOneField(Product)
  
      def cost(self, width, height, options):
          return sum([
          ])
  
      def labour_time(self, width, height, options):
          return datetime.timedelta(minutes=100)
  
      def CO2_output(self, width, height, options):
          return 100 # some kg measurement
  
  class Other(models.Model, mixins.GetRelatedClassesMixin):
      num_packages = models.OneToOneField(mixins.NumberPackages)
  
      product = models.OneToOneField(Product)
  
      def cost(self, width, height, options):
          return 100
  
      def labour_time(self, width, height, options):
          return datetime.timedelta(minutes=100)
  
      def CO2_output(self, width, height, options):
          return 100 # some kg measurement
  </code></pre>
  
  <p>mixins:</p>
  
  <pre><code>class TimeCostMixin(models.Model, GetFieldsMixin):
      cost = models.DecimalField(default=0.0, max_digits=10, decimal_places=2)
      time = models.TimeField(default=datetime.timedelta(0))
      class Meta:
          abstract = True
  
  ##### Frame #####
  class FrameComponentMixin(TimeCostMixin):
      external_width = models.IntegerField(default=0)
      material_weight = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
      u_value = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
      class Meta:
          abstract = True
  
  
  class TopFrame(FrameComponentMixin):
      pass
  
  
  class BottomFrame(FrameComponentMixin):
      pass
  
  
  class SideFrame(FrameComponentMixin):
      pass
  
  
  class Accessories(TimeCostMixin):
      material_weight = models.DecimalField(default=0.0,max_digits=10,decimal_places=2)
  
  
  class Flashing(TimeCostMixin):
      pass
  
  
  class Silicone(TimeCostMixin):
      labour_time = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  #################
  
  ##### Glass #####
  class GlassTypeA(TimeCostMixin):
      material_weight = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
      u_value = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  
  class GlassTypeB(TimeCostMixin):
      material_weight = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
      u_value = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  
  class Enhanced(TimeCostMixin):
      material_weight = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  
  class Laminate(TimeCostMixin):
      material_weight = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  
  class LowIron(TimeCostMixin):
      pass
  
  class Privacy(TimeCostMixin):
      pass
  
  class AntiSlip(TimeCostMixin):
      pass
  
  class HeatMirrorField(TimeCostMixin):
      u_value = models.DecimalField(default=0.0, max_digits=10,decimal_places=2)
  
  class PolishedEdges(models.Model):
      cost = models.DecimalField(default=0.0, max_digits=10, decimal_places=2)
  ##################
  
  ##### other  #####
  class NumberPackages(models.Model):
      number_of_packages = models.IntegerField(default=0)
  ##################
  </code></pre>
  
  <p>and a hair pulling admin!</p>
  
  <pre><code>class ProductAdmin(AdminImageMixin, admin.ModelAdmin):
      inlines = [ProductDownloadInline, ProductConfigurationInline]
  
      add_form_template = 'admin/products/add_form.html'
      change_form_template = 'admin/products/add_form.html'
  
  
      @csrf_protect_m
      @transaction.atomic
      def add_view(self, request, form_url='', extra_context=None):
          extra_context = extra_context or {}
  
          "The 'add' admin view for this model."
          model = self.model
          opts = model._meta
  
          if not self.has_add_permission(request):
              raise PermissionDenied
  
          ModelForm = self.get_form(request)
          formsets = []
          inline_instances = self.get_inline_instances(request, None)
          if request.method == 'POST':
              form = ModelForm(request.POST, request.FILES)
              if form.is_valid():
                  new_object = self.save_form(request, form, change=False)
                  form_validated = True
              else:
                  form_validated = False
                  new_object = self.model()
              prefixes = {}
              for FormSet, inline in zip(self.get_formsets(request), inline_instances):
                  prefix = FormSet.get_default_prefix()
                  prefixes[prefix] = prefixes.get(prefix, 0) + 1
                  if prefixes[prefix] != 1 or not prefix:
                      prefix = "%s-%s" % (prefix, prefixes[prefix])
                  formset = FormSet(data=request.POST, files=request.FILES,
                                    instance=new_object,
                                    save_as_new="_saveasnew" in request.POST,
                                    prefix=prefix, queryset=inline.get_queryset(request))
                  formsets.append(formset)
  
              #####
              outer_frame_forms = [
                  modelform_factory(cls)(request.POST, prefix='OuterFrame_'+cls.__name__)
                  for cls in models.OuterFrame.get_related_classes(exclude=['product'])
              ]
              inner_frame_forms = [
                  modelform_factory(cls)(request.POST, prefix='InnerFrame'+cls.__name__)
                  for cls in models.InnerFrame.get_related_classes(exclude=['product'])
              ]
              glass_forms = [
                  modelform_factory(cls)(request.POST, prefix='InnerFrame'+cls.__name__)
                  for cls in models.Glass.get_related_classes(exclude=['product'])
              ]
              other_forms = [
                  modelform_factory(cls)(request.POST, prefix='InnerFrame'+cls.__name__)
                  for cls in models.Other.get_related_classes(exclude=['product'])
              ]
              #####
  
              if all_valid(formsets
                          +outer_frame_forms
                          +inner_frame_forms
                          +glass_forms
                          +other_forms
                          ) and form_validated:
                  self.save_model(request, new_object, form, False)
                  self.save_related(request, form, formsets, False)
                  self.log_addition(request, new_object)
  
                  ##### save object hierichy #####
                  # inner frame
                  inner_frame = models.InnerFrame()
                  inner_frame.product = new_object
                  mapping = {f.rel.to:f.name for f in models.InnerFrame._meta.fields if f.name not in ['id','product']}
                  for f in inner_frame_forms:
                      obj = f.save()
                      setattr(inner_frame, mapping[obj.__class__], obj)
                  inner_frame.save()
                  # outer frame
                  outer_frame = models.OuterFrame()
                  outer_frame.product = new_object
                  mapping = {f.rel.to:f.name for f in models.OuterFrame._meta.fields if f.name not in ['id','product']}
                  for f in outer_frame_forms:
                      obj = f.save()
                      setattr(outer_frame, mapping[obj.__class__], obj)
                  outer_frame.save()
                  # glass
                  glass = models.Glass()
                  glass.product = new_object
                  mapping = {f.rel.to:f.name for f in models.Glass._meta.fields if f.name not in ['id','product']}
                  for f in glass_forms:
                      obj = f.save()
                      setattr(glass, mapping[obj.__class__], obj)
                  glass.save()
                  # other
                  other = models.Other()
                  other.product = new_object
                  mapping = {f.rel.to:f.name for f in models.Other._meta.fields if f.name not in ['id','product']}
                  for f in other_forms:
                      obj = f.save()
                      setattr(other, mapping[obj.__class__], obj)
                  other.save()
                  #################################
  
                  return self.response_add(request, new_object)
          else:
              forms = SortedDict({})
              forms['Outer Frame Variables'] = {
                  cls.__name__: modelform_factory(cls)(prefix='OuterFrame_'+cls.__name__)
                  for cls in models.OuterFrame.get_related_classes(exclude=['product'])
              }
              forms['Inner Frame Variables'] = {
                  cls.__name__: modelform_factory(cls)(prefix='InnerFrame'+cls.__name__)
                  for cls in models.InnerFrame.get_related_classes(exclude=['product'])
              }
              forms['Glass Variables'] = {
                  cls.__name__: modelform_factory(cls)(prefix='InnerFrame'+cls.__name__)
                  for cls in models.Glass.get_related_classes(exclude=['product'])
              }
              forms['Other Variables'] = {
                  cls.__name__: modelform_factory(cls)(prefix='InnerFrame'+cls.__name__)
                  for cls in models.Other.get_related_classes(exclude=['product'])
              }
              extra_context['forms'] = forms
  
              # Prepare the dict of initial data from the request.
              # We have to special-case M2Ms as a list of comma-separated PKs.
              initial = dict(request.GET.items())
              for k in initial:
                  try:
                      f = opts.get_field(k)
                  except models.FieldDoesNotExist:
                      continue
                  if isinstance(f, models.ManyToManyField):
                      initial[k] = initial[k].split(",")
              form = ModelForm(initial=initial)
              prefixes = {}
              for FormSet, inline in zip(self.get_formsets(request), inline_instances):
                  prefix = FormSet.get_default_prefix()
                  prefixes[prefix] = prefixes.get(prefix, 0) + 1
                  if prefixes[prefix] != 1 or not prefix:
                      prefix = "%s-%s" % (prefix, prefixes[prefix])
                  formset = FormSet(instance=self.model(), prefix=prefix,
                                    queryset=inline.get_queryset(request))
                  formsets.append(formset)
  
          adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
              self.get_prepopulated_fields(request),
              self.get_readonly_fields(request),
              model_admin=self)
          media = self.media + adminForm.media
  
          inline_admin_formsets = []
          for inline, formset in zip(inline_instances, formsets):
              fieldsets = list(inline.get_fieldsets(request))
              readonly = list(inline.get_readonly_fields(request))
              prepopulated = dict(inline.get_prepopulated_fields(request))
              inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
                  fieldsets, prepopulated, readonly, model_admin=self)
              inline_admin_formsets.append(inline_admin_formset)
              media = media + inline_admin_formset.media
  
          context = {
              'title': _('Add %s') % force_text(opts.verbose_name),
              'adminform': adminForm,
              'is_popup': IS_POPUP_VAR in request.REQUEST,
              'media': media,
              'inline_admin_formsets': inline_admin_formsets,
              'errors': helpers.AdminErrorList(form, formsets),
              'app_label': opts.app_label,
              'preserved_filters': self.get_preserved_filters(request),
          }
          context.update(extra_context or {})
          return self.render_change_form(request, context, form_url=form_url, add=True)
  </code></pre>