├── .gitignore ├── README.md ├── __init__.py ├── django_reverse_admin └── __init__.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | dist 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Reverse Admin 2 | 3 | Module that makes django admin handle OneToOneFields in a better way. 4 | A common use case for one-to-one relationships is to "embed" a model 5 | inside another one. For example, a Person may have multiple foreign 6 | keys pointing to an Address entity, one home address, one business 7 | address and so on. Django admin displays those relations using select 8 | boxes, letting the user choose which address entity to connect to a 9 | person. A more natural way to handle the relationship is using 10 | inlines. However, since the foreign key is placed on the owning 11 | entity, django admins standard inline classes can't be used. Which is 12 | why I created this module that implements "reverse inlines" for this 13 | use case. 14 | 15 | Fix/extension of: 16 | * [adminreverse](https://github.com/rpkilby/django-reverse-admin) 17 | * [reverseadmin](http://djangosnippets.org/snippets/2032/) 18 | 19 | Made to work with django 1.10 20 | 21 | ## Example 22 | 23 | `models.py` file 24 | ```py 25 | 26 | from django.db import models 27 | class Address(models.Model): 28 | street = models.CharField(max_length = 255) 29 | zipcode = models.CharField(max_length=10) 30 | city = models.CharField(max_length=255) 31 | class Person(models.Model): 32 | name = models.CharField(max_length = 255) 33 | business_addr = models.ForeignKey(Address, 34 | related_name = 'business_addr') 35 | home_addr = models.OneToOneField(Address, related_name = 'home_addr') 36 | other_addr = models.OneToOneField(Address, related_name = 'other_addr') 37 | ``` 38 | 39 | `admin.py` file 40 | ```py 41 | from django.contrib import admin 42 | from django.db import models 43 | from models import Person 44 | from django_reverse_admin import ReverseModelAdmin 45 | 46 | 47 | class PersonAdmin(ReverseModelAdmin): 48 | inline_type = 'tabular' 49 | inline_reverse = ['business_addr', 50 | ('home_addr', {'fields': ['street', 'city', 'state', 'zip']}), 51 | ] 52 | admin.site.register(Person, PersonAdmin) 53 | ``` 54 | 55 | inline_type can be either "tabular" or "stacked" for tabular and 56 | stacked inlines respectively. 57 | 58 | The module is designed to work with Django 1.10. Since it hooks into 59 | the internals of the admin package, it may not work with later Django 60 | versions. 61 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anziem/django_reverse_admin/c785d5a85c1342e234e893f90d5895ed4fd8e0b8/__init__.py -------------------------------------------------------------------------------- /django_reverse_admin/__init__.py: -------------------------------------------------------------------------------- 1 | #pylint: skip-file 2 | from django.contrib.admin import helpers, ModelAdmin 3 | from django.contrib.admin.options import InlineModelAdmin 4 | from django.contrib.admin.utils import flatten_fieldsets 5 | from django.db import models 6 | from django.db.models import OneToOneField, ForeignKey 7 | from django.forms import ModelForm 8 | from django.forms.formsets import all_valid 9 | from django.forms.models import BaseModelFormSet, modelformset_factory 10 | from django.utils.encoding import force_text 11 | from django.utils.functional import curry 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import ugettext as _ 14 | from django.core.exceptions import PermissionDenied 15 | 16 | class ReverseInlineFormSet(BaseModelFormSet): 17 | ''' 18 | A formset with either a single object or a single empty 19 | form. Since the formset is used to render a required OneToOne 20 | relation, the forms must not be empty. 21 | ''' 22 | parent_fk_name = '' 23 | def __init__(self, 24 | data=None, 25 | files=None, 26 | instance=None, 27 | prefix=None, 28 | queryset=None, 29 | save_as_new=False): 30 | object = getattr(instance, self.parent_fk_name, None) 31 | if object: 32 | qs = self.model.objects.filter(pk=object.id) 33 | else: 34 | qs = self.model.objects.filter(pk=-1) 35 | self.extra = 1 36 | super(ReverseInlineFormSet, self).__init__(data, files, 37 | prefix=prefix, 38 | queryset=qs) 39 | for form in self.forms: 40 | form.empty_permitted = False 41 | 42 | def reverse_inlineformset_factory(parent_model, 43 | model, 44 | parent_fk_name, 45 | form=ModelForm, 46 | fields=None, 47 | exclude=None, 48 | formfield_callback=lambda f: f.formfield()): 49 | 50 | if fields is None and exclude is None: 51 | related_fields = [f for f in model._meta.get_fields() if 52 | (f.one_to_many or f.one_to_one) and 53 | f.auto_created and not f.concrete] 54 | fields = [f.name for f in model._meta.get_fields() if f not in 55 | related_fields] # ignoring reverse relations 56 | kwargs = { 57 | 'form': form, 58 | 'formfield_callback': formfield_callback, 59 | 'formset': ReverseInlineFormSet, 60 | 'extra': 0, 61 | 'can_delete': False, 62 | 'can_order': False, 63 | 'fields': fields, 64 | 'exclude': exclude, 65 | 'max_num': 1, 66 | } 67 | FormSet = modelformset_factory(model, **kwargs) 68 | FormSet.parent_fk_name = parent_fk_name 69 | return FormSet 70 | 71 | class ReverseInlineModelAdmin(InlineModelAdmin): 72 | ''' 73 | Use the name and the help_text of the owning models field to 74 | render the verbose_name and verbose_name_plural texts. 75 | ''' 76 | def __init__(self, 77 | parent_model, 78 | parent_fk_name, 79 | model, admin_site, 80 | inline_type): 81 | self.template = 'admin/edit_inline/%s.html' % inline_type 82 | self.parent_fk_name = parent_fk_name 83 | self.model = model 84 | field_descriptor = getattr(parent_model, self.parent_fk_name) 85 | field = field_descriptor.field 86 | 87 | self.verbose_name_plural = field.verbose_name.title() 88 | self.verbose_name = field.help_text 89 | if not self.verbose_name: 90 | self.verbose_name = self.verbose_name_plural 91 | super(ReverseInlineModelAdmin, self).__init__(parent_model, admin_site) 92 | 93 | def get_formset(self, request, obj=None, **kwargs): 94 | if 'fields' in kwargs: 95 | fields = kwargs.pop('fields') 96 | elif self.get_fieldsets(request, obj): 97 | fields = flatten_fieldsets(self.get_fieldsets(request, obj)) 98 | else: 99 | fields = None 100 | 101 | # want to combine exclude arguments - can't do that if they're None 102 | # also, exclude starts as a tuple - need to make it a list 103 | exclude = list(kwargs.get("exclude", [])) 104 | exclude_2 = self.exclude or [] 105 | # but need exclude to be None if result is an empty list 106 | exclude = exclude.extend(list(exclude_2)) or None 107 | 108 | defaults = { 109 | "form": self.form, 110 | "fields": fields, 111 | "exclude": exclude, 112 | "formfield_callback": curry(self.formfield_for_dbfield, request=request), 113 | } 114 | kwargs.update(defaults) 115 | return reverse_inlineformset_factory(self.parent_model, 116 | self.model, 117 | self.parent_fk_name, 118 | **kwargs) 119 | 120 | class ReverseModelAdmin(ModelAdmin): 121 | ''' 122 | Patched ModelAdmin class. The add_view method is overridden to 123 | allow the reverse inline formsets to be saved before the parent 124 | model. 125 | ''' 126 | def __init__(self, model, admin_site): 127 | 128 | super(ReverseModelAdmin, self).__init__(model, admin_site) 129 | if self.exclude is None: 130 | self.exclude = [] 131 | self.exclude = list(self.exclude) 132 | 133 | inline_instances = [] 134 | for field_name in self.inline_reverse: 135 | 136 | kwargs = {} 137 | if isinstance(field_name, tuple): 138 | kwargs = field_name[1] 139 | field_name = field_name[0] 140 | 141 | field = model._meta.get_field(field_name) 142 | if isinstance(field, (OneToOneField, ForeignKey)): 143 | name = field.name 144 | parent = field.remote_field.model 145 | inline = ReverseInlineModelAdmin(model, 146 | name, 147 | parent, 148 | admin_site, 149 | self.inline_type) 150 | if kwargs: 151 | inline.__dict__.update(kwargs) 152 | inline_instances.append(inline) 153 | self.exclude.append(name) 154 | self.tmp_inline_instances = inline_instances 155 | 156 | def get_inline_instances(self, request, obj=None): 157 | return self.tmp_inline_instances + super(ReverseModelAdmin, self).get_inline_instances(request, obj) 158 | 159 | def change_view(self, request, object_id, form_url='', extra_context=None): 160 | return self.changeform_view(request, object_id, form_url, extra_context) 161 | 162 | def add_view(self, request, form_url='', extra_context=None): 163 | "The 'add' admin view for this model." 164 | model = self.model 165 | opts = model._meta 166 | if not self.has_add_permission(request): 167 | raise PermissionDenied 168 | 169 | model_form = self.get_form(request) 170 | formsets = [] 171 | if request.method == 'POST': 172 | form = model_form(request.POST, request.FILES) 173 | if form.is_valid(): 174 | form_validated = True 175 | new_object = self.save_form(request, form, change=False) 176 | else: 177 | form_validated = False 178 | new_object = self.model() 179 | prefixes = {} 180 | for FormSet, inline in self.get_formsets_with_inlines(request): 181 | prefix = FormSet.get_default_prefix() 182 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 183 | if prefixes[prefix] != 1: 184 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 185 | formset = FormSet(data=request.POST, files=request.FILES, 186 | instance=new_object, 187 | save_as_new="_saveasnew" in request.POST, 188 | prefix=prefix) 189 | formsets.append(formset) 190 | if all_valid(formsets) and form_validated: 191 | # Here is the modified code. 192 | for formset, inline in zip(formsets, self.get_inline_instances(request)): 193 | if not isinstance(inline, ReverseInlineModelAdmin): 194 | continue 195 | obj = formset.save()[0] 196 | setattr(new_object, inline.parent_fk_name, obj) 197 | self.save_model(request, new_object, form, change=False) 198 | form.save_m2m() 199 | for formset in formsets: 200 | self.save_formset(request, form, formset, change=False) 201 | 202 | #self.log_addition(request, new_object) 203 | return self.response_add(request, new_object) 204 | else: 205 | # Prepare the dict of initial data from the request. 206 | # We have to special-case M2Ms as a list of comma-separated PKs. 207 | initial = dict(request.GET.items()) 208 | for k in initial: 209 | try: 210 | f = opts.get_field(k) 211 | except models.FieldDoesNotExist: 212 | continue 213 | if isinstance(f, models.ManyToManyField): 214 | initial[k] = initial[k].split(",") 215 | form = model_form(initial=initial) 216 | prefixes = {} 217 | for FormSet, inline in self.get_formsets_with_inlines(request): 218 | prefix = FormSet.get_default_prefix() 219 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 220 | if prefixes[prefix] != 1: 221 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 222 | formset = FormSet(instance=self.model(), prefix=prefix) 223 | formsets.append(formset) 224 | 225 | adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)), self.prepopulated_fields) 226 | media = self.media + adminForm.media 227 | 228 | inline_admin_formsets = [] 229 | for inline, formset in zip(self.get_inline_instances(request), formsets): 230 | fieldsets = list(inline.get_fieldsets(request)) 231 | inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets) 232 | inline_admin_formsets.append(inline_admin_formset) 233 | media = media + inline_admin_formset.media 234 | 235 | context = { 236 | 'title': _('Add %s') % force_text(opts.verbose_name), 237 | 'adminform': adminForm, 238 | #'is_popup': '_popup' in request.REQUEST, 239 | 'is_popup': False, 240 | 'show_delete': False, 241 | 'media': mark_safe(media), 242 | 'inline_admin_formsets': inline_admin_formsets, 243 | 'errors': helpers.AdminErrorList(form, formsets), 244 | #'root_path': self.admin_site.root_path, 245 | 'app_label': opts.app_label, 246 | } 247 | context.update(extra_context or {}) 248 | return self.render_change_form(request, context, form_url=form_url, add=True) 249 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wsgiref==0.1.2 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='Django Reverse Admin', 5 | version='0.0.1', 6 | packages=['django_reverse_admin',], 7 | license='Creative Commons Attribution-Noncommercial-Share Alike license', 8 | long_description=open('README.md').read(), 9 | ) 10 | --------------------------------------------------------------------------------