Search code examples
pythondjangodjango-modelsdjango-admindjango-modeladmin

How to CRUD ContentType in Django admin site?


I'm reading a book about Django and I'm trying to reinterpret some content. (I'm using Django 2.1 and Python 3.6) In the book different types of contents are associated with a module, like this:

class Module(models.Model):
    course = models.ForeignKey(Course,
                        related_name='modules',
                        on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    order = OrderField(blank=True, for_fields=['course'])

    class Meta:
        ordering = ['order']

    def __str__(self):
        return '{}. {}'.format(self.order, self.title)

class Content(models.Model):
    module = models.ForeignKey(Module,
                        related_name='contents',
                        on_delete=models.CASCADE)
    content_type = models.ForeignKey(ContentType,
                                on_delete=models.CASCADE,
                                limit_choices_to={'model__in': (
                                    'text',
                                    'video',
                                    'image',
                                    'file')})
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')
    order = OrderField(blank=True, for_fields=['module'])

    class Meta:
        ordering = ['order']

class ItemBase(models.Model):
    owner = models.ForeignKey(User,
                        related_name='%(class)s_related',
                        on_delete=models.CASCADE)
    title = models.CharField(max_length=250)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

    def __str__(self):
        return self.title

    def render(self):
        return render_to_string(
             'courses/content/{}.html'.format(self._meta.model_name),
             {'item': self})

class Text(ItemBase):
    content = models.TextField()

class File(ItemBase):
    file = models.FileField(upload_to='files')

class Image(ItemBase):
    file = models.FileField(upload_to='images')

class Video(ItemBase):
    url = models.URLField()

In the book there are CBVs to generate the right form for content types:

class ContentCreateUpdateView(TemplateResponseMixin, View):
    module = None
    model = None
    obj = None
    template_name = 'courses/manage/content/form.html'

    def get_model(self, model_name):
        if model_name in ['text', 'video', 'image', 'file']:
            return apps.get_model(app_label='courses',
                                model_name=model_name)
        return None

    def get_form(self, model, *args, **kwargs):
        Form = modelform_factory(model, exclude=['owner',
                                             'order',
                                             'created',
                                             'updated'])
        return Form(*args, **kwargs)

    def dispatch(self, request, module_id, model_name, id=None):
        self.module = get_object_or_404(Module,
                                   id=module_id,
                                   course__owner=request.user)
        self.model = self.get_model(model_name)
        if id:
            self.obj = get_object_or_404(self.model,
                                     id=id,
                                     owner=request.user)
        return super(ContentCreateUpdateView,
           self).dispatch(request, module_id, model_name, id)

    def get(self, request, module_id, model_name, id=None):
        form = self.get_form(self.model, instance=self.obj)
        return self.render_to_response({'form': form,
                                    'object': self.obj})

    def post(self, request, module_id, model_name, id=None):
        form = self.get_form(self.model,
                         instance=self.obj,
                         data=request.POST,
                         files=request.FILES)
        if form.is_valid():
            obj = form.save(commit=False)
            obj.owner = request.user
            obj.save()
            if not id:
                # new content
                Content.objects.create(module=self.module,
                                   item=obj)
            return redirect('module_content_list', self.module.id)
        return self.render_to_response({'form': form,
                                        'object': self.obj})

And contents are provided by users with special permissions. Ok and now the question: I want the contents to be managed only by the admin, in the admin site. I've tried this way:

class ContentInline(GenericStackedInline):
    model = Content
    extra = 0

@admin.register(Module)
class ModuleAdmin(admin.ModelAdmin):
    list_display = ['title', 'order', 'course']
    list_filter = ['course']
    search_fields = ['title', 'description']
    inlines = [ContentInline]

@admin.register(Content)
class ContentAdmin(admin.ModelAdmin):
    list_display = ['object_id', 'module', 'content_type', 'order']

but I have not been able to display in the admin site forms the field to upload the right content type. Module page looks like this:

module page in admin site

While Content page is this:

content page in admin site

I've been searching for a solution for a while but I've not been able to find one. There is a similar topic here: link but suggested solutions imply javascript and/or additional packages while I'd like to do that only with Python and Django. I've also read that a possible solution to display custom views in admin site is to write a view and then add it to the admin urls, then add it to the admin model. Someone else suggested to use CBV from the book and use it in model admin. I've tried to implement these suggestions with no luck. In the last version of my code I try to use the CBV with as_view() this way (in views.py right after CBV):

content_create_update_view = ContentCreateUpdateView.as_view()

and then in admin.py:

@admin.register(Content)
class ContentAdmin(admin.ModelAdmin):
    list_display = ['object_id', 'module', 'content_type', 'order']

    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path('content/<int:object_id>/change/', self.admin_site.admin_view(content_create_update_view)),
        ]
        return my_urls + urls

any help or suggestion is greatly appreciated.


Solution

  • In the end I was not able to find a solution to my problem, at least in the way I wanted to. So I tried to get around the problem to get the same result in other ways. While looking for different methods, such as not using the GenericForeignKey or the ContentTypes I came across this link:avoid Django's GenericForeignKey It seemed clear and simple, although maybe a little 'less elegant, so I implemented the following solution.

    1) I modified the Content class by removing the GenericForeignKey and replacing it with a OneToOne relation for each type of supported content:

    text = models.OneToOneField(Text, null=True, blank=True, on_delete=models.CASCADE)
    file = models.OneToOneField(File, null=True, blank=True, on_delete=models.CASCADE)
    image = models.OneToOneField(Image, null=True, blank=True, on_delete=models.CASCADE)
    video = models.OneToOneField(Video, null=True, blank=True, on_delete=models.CASCADE)
    

    and to make sure that only one attachment was matched at a time for each content, I added a check overwriting the save function:

    def save(self, **kwargs):
        assert [self.text, self.file, self.image, self.video].count(None) == 3
        return super().save(**kwargs)
    

    Finally I added a property to the class that would return the type of content:

    @property
    def target(self):
        if self.text_id is not None:
            return self.text
        if self.file_id is not None:
            return self.file
        if self.image_id is not None:
            return self.image
        if self.video_id is not None:
            return self.video
        raise AssertionError("You have to set content!")
    

    2) I modified the admin.py file by adding all the expected types of Content:

    @admin.register(Text)
    class TextAdmin(admin.ModelAdmin):
        list_display = ['title', 'created', 'updated']
    
    @admin.register(File)
    class FileAdmin(admin.ModelAdmin):
        list_display = ['title', 'created', 'updated']
    
    @admin.register(Image)
    class ImageAdmin(admin.ModelAdmin):
        list_display = ['title', 'created', 'updated']
    
    @admin.register(Video)
    class VideoAdmin(admin.ModelAdmin):
        list_display = ['title', 'created', 'updated']
    

    Then I modified the ContentAdmin class by overriding the get_queryset function:

    @admin.register(Content)
    class ContentAdmin(admin.ModelAdmin):
    
        def get_queryset(self, request):
            qs = super(ContentAdmin, self).get_queryset(request)
            qs = qs.select_related('text',
                                   'file',
                                   'image',
                                   'video')
            return qs
    

    In this way, I am able to load the attachments in the admin interface and then combine them with the desired Content by selecting them conveniently from a list. Unfortunately this is a procedure in 2 steps, I would prefer to solve everything in one form admin side, but... it works.