From e07229bbd3bd4cedcbca1d890d814dec55d967ad Mon Sep 17 00:00:00 2001 From: Ndibe Raymond Olisaemeka Date: Wed, 17 Jul 2024 00:26:15 +0100 Subject: [PATCH] [backend] add activity to admin management * minor modifications to the admin model * add admin models management to admin.py * implement effective signals to handle admin models instances, addition, modification and deletion with proper cleanup Issue: #1094 Signed-off-by: Ndibe Raymond Olisaemeka --- zubhub_backend/zubhub/activities/admin.py | 103 +++++++++- zubhub_backend/zubhub/activities/apps.py | 5 +- .../migrations/0018_alter_activity_options.py | 16 ++ zubhub_backend/zubhub/activities/models.py | 148 ++++++++------ .../zubhub/activities/serializers.py | 190 ++++++++++-------- zubhub_backend/zubhub/activities/signals.py | 107 ++++++++++ 6 files changed, 414 insertions(+), 155 deletions(-) create mode 100644 zubhub_backend/zubhub/activities/migrations/0018_alter_activity_options.py create mode 100644 zubhub_backend/zubhub/activities/signals.py diff --git a/zubhub_backend/zubhub/activities/admin.py b/zubhub_backend/zubhub/activities/admin.py index 8c38f3f3d..8db029f71 100644 --- a/zubhub_backend/zubhub/activities/admin.py +++ b/zubhub_backend/zubhub/activities/admin.py @@ -1,3 +1,102 @@ -from django.contrib import admin +from django.contrib import admin, messages -# Register your models here. +from .models import ( + Activity, + ActivityImage, + ActivityMakingStep, + Image, + InspiringArtist, + InspiringExample, +) + + +class InlineActivityImages(admin.StackedInline): + model = ActivityImage + + +class InlineActivityMakingSteps(admin.StackedInline): + model = ActivityMakingStep + + +class InlineInspiringExamples(admin.StackedInline): + model = InspiringExample + + +class InspiringArtistAdmin(admin.ModelAdmin): + search_fields = ("name",) + list_display = ( + "name", + "image", + ) + list_filter = ("name",) + + +class ActivityAdmin(admin.ModelAdmin): + list_display = ("title", "id", "created_on", "publish") + list_filter = ( + "created_on", + "publish", + ) + search_fields = ( + "title", + "id", + "category", + ) + ordering = ["-created_on"] + actions = ["publish", "un_publish", "delete_selected"] + inlines = [InlineActivityImages, InlineActivityMakingSteps, InlineInspiringExamples] + list_per_page = 50 # paginate when more than 50 items + + def un_publish(self, request, queryset): + """ + This function is used to unpublish selected activities + """ + queryset.update(publish=False) + messages.success(request, "Selected records were unpublished successfully.") + + def publish(self, request, queryset): + """ + This function is used to publish selected activities + """ + queryset = queryset.filter(publish=False) + queryset.update(publish=True) + messages.success(request, "Selected records were published successfully.") + + def delete_selected(self, request, queryset): + """ + This function is used to delete selected activities + """ + queryset.delete() + messages.success(request, "Selected records were deleted successfully.") + + def get_readonly_fields(self, request, obj=None): + return [ + "id", + "created_on", + "views_count", + "saved_by", + "views", + "saved_count", + "slug", + ] + + un_publish.short_description = "Unpublish selected activities" + publish.short_description = "Publish selected activities" + delete_selected.short_description = "Delete selected activities" + + +class ActivityImageAdmin(admin.ModelAdmin): + search_fields = ["activity__title", "activity__id", "image__public_id"] + list_display = ["activity", "image"] + + +class ImageAdmin(admin.ModelAdmin): + search_fields = ["public_id"] + list_display = ["public_id", "file_url"] + # should not be able to edit this from the admin panel ? + + +admin.site.register(InspiringArtist, InspiringArtistAdmin) +admin.site.register(Activity, ActivityAdmin) +admin.site.register(ActivityImage, ActivityImageAdmin) +admin.site.register(Image, ImageAdmin) diff --git a/zubhub_backend/zubhub/activities/apps.py b/zubhub_backend/zubhub/activities/apps.py index 39325dd9e..cf2d63446 100644 --- a/zubhub_backend/zubhub/activities/apps.py +++ b/zubhub_backend/zubhub/activities/apps.py @@ -2,4 +2,7 @@ class ActivitiesConfig(AppConfig): - name = 'activities' + name = "activities" + + def ready(self): + import activities.signals # noqa: F401 diff --git a/zubhub_backend/zubhub/activities/migrations/0018_alter_activity_options.py b/zubhub_backend/zubhub/activities/migrations/0018_alter_activity_options.py new file mode 100644 index 000000000..3439b015b --- /dev/null +++ b/zubhub_backend/zubhub/activities/migrations/0018_alter_activity_options.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2 on 2024-06-24 16:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("activities", "0017_alter_activity_materials_used"), + ] + + operations = [ + migrations.AlterModelOptions( + name="activity", + options={"verbose_name_plural": "Activities"}, + ), + ] diff --git a/zubhub_backend/zubhub/activities/models.py b/zubhub_backend/zubhub/activities/models.py index 6251fd589..4866ae857 100644 --- a/zubhub_backend/zubhub/activities/models.py +++ b/zubhub_backend/zubhub/activities/models.py @@ -1,32 +1,33 @@ import uuid -from django.db import models +from math import floor + from django.contrib.auth import get_user_model -from django.utils.text import slugify +from django.db import models from django.utils import timezone -from math import floor -from projects.models import Category +from django.utils.text import slugify Creator = get_user_model() +# TODO: use a single model for images everywhere class Image(models.Model): file_url = models.URLField(max_length=1000) public_id = models.TextField(max_length=1000, blank=True) def __str__(self): try: - image = self.file_url + file_url = self.file_url except AttributeError: - image = '' - return "Photo <%s:%s>" % (self.public_id, image) + file_url = "" + return "Photo <%s:%s>" % (self.public_id, file_url) class InspiringArtist(models.Model): - '''this should be having more fields to distinguish an artist ''' - image = models.ForeignKey(Image, - on_delete=models.CASCADE, - null=True, - blank=True) + """this should be having more fields to distinguish an artist""" + + image = models.ForeignKey( + Image, on_delete=models.CASCADE, null=True, blank=True + ) # TODO: change to OneToOneField short_biography = models.TextField(max_length=10000, blank=True, null=True) name = models.CharField(max_length=100, null=True) @@ -35,41 +36,45 @@ def __str__(self): class Activity(models.Model): - id = models.UUIDField(primary_key=True, - default=uuid.uuid4, - editable=False, - unique=True) - creators = models.ManyToManyField(Creator, - related_name="activities_created") + id = models.UUIDField( + primary_key=True, default=uuid.uuid4, editable=False, unique=True + ) + creators = models.ManyToManyField(Creator, related_name="activities_created") title = models.CharField(max_length=500) - category = models.ManyToManyField("projects.Category",blank=True, related_name="activities") - introduction = models.CharField(max_length=10000,blank=True) + category = models.ManyToManyField( + "projects.Category", blank=True, related_name="activities" + ) + introduction = models.CharField(max_length=10000, blank=True) class_grade = models.CharField(max_length=50, blank=True) - + learning_goals = models.TextField(max_length=10000, blank=True, null=True) facilitation_tips = models.TextField(max_length=10000, blank=True, null=True) motivation = models.TextField(max_length=10000, blank=True, null=True) video = models.URLField(max_length=1000, blank=True, null=True) materials_used = models.TextField(max_length=5000, blank=True, null=True) - materials_used_image = models.ForeignKey(Image, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - inspiring_artist = models.ForeignKey(InspiringArtist, - on_delete=models.SET_NULL, - null=True, - related_name="inspiring_artist_activities", - blank=True, - ) - views = models.ManyToManyField(Creator, - blank=True, - related_name="activities_viewed") + materials_used_image = models.ForeignKey( # TODO: change to OneToOneField + Image, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + inspiring_artist = ( + models.ForeignKey( # TODO: sure an activity can only be inspired by one artist? + InspiringArtist, + on_delete=models.SET_NULL, + null=True, + related_name="activities_inspired", + blank=True, + ) + ) + views = models.ManyToManyField( + Creator, blank=True, related_name="activities_viewed" + ) views_count = models.IntegerField(blank=True, default=0) saved_count = models.IntegerField(blank=True, default=0) - saved_by = models.ManyToManyField(Creator, - blank=True, - related_name="activities_saved") + saved_by = models.ManyToManyField( + Creator, blank=True, related_name="activities_saved" + ) created_on = models.DateTimeField(default=timezone.now, null=True) publish = models.BooleanField(default=False, null=True) slug = models.SlugField(unique=True, max_length=1000) @@ -79,7 +84,7 @@ def save(self, *args, **kwargs): pass else: uid = str(uuid.uuid4()) - uid = uid[0:floor(len(uid) / 6)] + uid = uid[0 : floor(len(uid) / 6)] self.slug = slugify(self.title) + "-" + uid super().save(*args, **kwargs) @@ -87,48 +92,57 @@ def save(self, *args, **kwargs): def __str__(self): return self.title + class Meta: + verbose_name_plural = "Activities" + class InspiringExample(models.Model): - activity = models.ForeignKey(Activity, - on_delete=models.CASCADE, - null=True, - related_name="inspiring_examples", - blank=True) + activity = models.ForeignKey( + Activity, + on_delete=models.CASCADE, + null=True, + related_name="inspiring_examples", + blank=True, + ) description = models.TextField(max_length=10000, blank=True) credit = models.TextField(max_length=1000, blank=True) - image = models.ForeignKey(Image, - on_delete=models.CASCADE, - null=True, - blank=True) + image = models.ForeignKey( + Image, on_delete=models.CASCADE, null=True, blank=True + ) # TODO: change to OneToOneField def __str__(self): - return self.image + return self.image.file_url class ActivityImage(models.Model): - activity = models.ForeignKey(Activity, - on_delete=models.CASCADE, - null=True, - related_name="activity_images", - blank=True) - image = models.ForeignKey(Image, - on_delete=models.CASCADE, - null=True, - blank=True) + activity = models.ForeignKey( + Activity, + on_delete=models.CASCADE, + null=True, + related_name="activity_images", + blank=True, + ) + image = models.ForeignKey( + Image, on_delete=models.CASCADE, null=True, blank=True + ) # TODO: change to OneToOneField def __str__(self): - return self.image + return self.image.file_url class ActivityMakingStep(models.Model): - activity = models.ForeignKey(Activity, - on_delete=models.CASCADE, - null=True, - related_name="making_steps", - blank=True) - - title = models.TextField(max_length=500,null=True) - image = models.ManyToManyField(Image,blank=True) + activity = models.ForeignKey( + Activity, + on_delete=models.CASCADE, + null=True, + related_name="making_steps", + blank=True, + ) + + title = models.TextField(max_length=500, null=True) + image = models.ManyToManyField( + Image, blank=True + ) # TODO: should this be ManyToManyField or OneToOneField ? description = models.TextField(max_length=10000, blank=True) step_order = models.IntegerField() diff --git a/zubhub_backend/zubhub/activities/serializers.py b/zubhub_backend/zubhub/activities/serializers.py index 4c6099955..e56185fed 100644 --- a/zubhub_backend/zubhub/activities/serializers.py +++ b/zubhub_backend/zubhub/activities/serializers.py @@ -1,22 +1,35 @@ - -from rest_framework import serializers -from django.contrib.auth import get_user_model -from .models import * -from projects.serializers import CategorySerializer, ProjectSerializer from creators.serializers import CreatorMinimalSerializer -from .utils import * +from django.contrib.auth import get_user_model +from projects.models import Category +from projects.serializers import ProjectSerializer +from rest_framework import serializers + +from .models import ( + Activity, + ActivityImage, + ActivityMakingStep, + Image, + InspiringArtist, + InspiringExample, +) +from .utils import ( + create_activity_images, + create_inspiring_artist, + create_inspiring_examples, + create_making_steps, + update_activity_images, + update_image, + update_inspiring_examples, + update_making_steps, +) Creator = get_user_model() class ImageSerializer(serializers.ModelSerializer): - class Meta: model = Image - fields = [ - "file_url", - "public_id" - ] + fields = ["file_url", "public_id"] class InspiringArtistSerializer(serializers.ModelSerializer): @@ -24,12 +37,7 @@ class InspiringArtistSerializer(serializers.ModelSerializer): class Meta: model = InspiringArtist - fields = [ - "id", - "name", - "short_biography", - "image" - ] + fields = ["id", "name", "short_biography", "image"] class ActivityImageSerializer(serializers.ModelSerializer): @@ -37,20 +45,16 @@ class ActivityImageSerializer(serializers.ModelSerializer): class Meta: model = ActivityImage - fields = [ - "image" - ] + fields = ["image"] class ActivityMakingStepSerializer(serializers.ModelSerializer): - image = ImageSerializer(required=False, allow_null=True, many=True ) + image = ImageSerializer(required=False, allow_null=True, many=True) step_order = serializers.IntegerField() class Meta: model = ActivityMakingStep - fields = [ - "image", "description", "step_order", "title" - ] + fields = ["image", "description", "step_order", "title"] class InspiringExampleSerializer(serializers.ModelSerializer): @@ -58,19 +62,18 @@ class InspiringExampleSerializer(serializers.ModelSerializer): class Meta: model = InspiringExample - fields = [ - "image", "description", "credit" - ] + fields = ["image", "description", "credit"] class ActivitySerializer(serializers.ModelSerializer): creators = CreatorMinimalSerializer(read_only=True, many=True) - saved_by = serializers.SlugRelatedField( - many=True, slug_field='id', read_only=True) + saved_by = serializers.SlugRelatedField(many=True, slug_field="id", read_only=True) images = ActivityImageSerializer( - many=True, required=False, source="activity_images") + many=True, required=False, source="activity_images" + ) category = serializers.SlugRelatedField( - slug_field="name", queryset=Category.objects.all(), many=True, required=False) + slug_field="name", queryset=Category.objects.all(), many=True, required=False + ) created_on = serializers.DateTimeField(read_only=True) views_count = serializers.IntegerField(read_only=True) saved_count = serializers.IntegerField(read_only=True) @@ -105,99 +108,116 @@ class Meta: "publish", "making_steps", "inspiring_examples", - "materials_used_image" + "materials_used_image", ] def create(self, validated_data): - if 'inspiring_artist' in validated_data: - validated_data['inspiring_artist'] = create_inspiring_artist( - validated_data.pop('inspiring_artist')) - if 'materials_used_image' in validated_data: - validated_data['materials_used_image'] = Image.objects.create( - **validated_data['materials_used_image']) + if "inspiring_artist" in validated_data: + validated_data["inspiring_artist"] = create_inspiring_artist( + validated_data.pop("inspiring_artist") + ) + if "materials_used_image" in validated_data: + validated_data["materials_used_image"] = Image.objects.create( + **validated_data["materials_used_image"] + ) - activity_images = validated_data.pop('activity_images', None) + activity_images = validated_data.pop("activity_images", None) - making_steps = validated_data.pop('making_steps', None) + making_steps = validated_data.pop("making_steps", None) - inspiring_examples = validated_data.pop('inspiring_examples', None) - category = validated_data.pop('category',None) + inspiring_examples = validated_data.pop("inspiring_examples", None) + category = validated_data.pop("category", None) activity = Activity.objects.create(**validated_data) - if(category): + if category: activity.category.set(category) if making_steps: create_making_steps(activity, making_steps) if inspiring_examples: - create_inspiring_examples( - activity, inspiring_examples) + create_inspiring_examples(activity, inspiring_examples) if activity_images: create_activity_images(activity, activity_images) activity.creators.add(self.context["request"].user) return activity def update(self, activity, validated_data): - if (activity.inspiring_artist is not None or 'inspiring_artist' in validated_data): - if(activity.inspiring_artist is not None): - if('inspiring_artist' in validated_data): - if(activity.inspiring_artist.image is not None or validated_data['inspiring_artist'].get('image') is not None): + if ( + activity.inspiring_artist is not None + or "inspiring_artist" in validated_data + ): + if activity.inspiring_artist is not None: + if "inspiring_artist" in validated_data: + if ( + activity.inspiring_artist.image is not None + or validated_data["inspiring_artist"].get("image") is not None + ): activity.inspiring_artist.image = update_image( - activity.inspiring_artist.image, validated_data['inspiring_artist'].get('image')) + activity.inspiring_artist.image, + validated_data["inspiring_artist"].get("image"), + ) - activity.inspiring_artist.name = validated_data['inspiring_artist'].get( - 'name') + activity.inspiring_artist.name = validated_data[ + "inspiring_artist" + ].get("name") activity.inspiring_artist.short_biography = validated_data[ - 'inspiring_artist'].get('short_biography') + "inspiring_artist" + ].get("short_biography") activity.inspiring_artist.save() - + else: activity.inspiring_artist.delete() activity.inspiring_artist = None else: - if('image' in validated_data['inspiring_artist']): - validated_data['inspiring_artist']['image'] = Image.objects.create( - **validated_data['inspiring_artist']['image']) + if "image" in validated_data["inspiring_artist"]: + validated_data["inspiring_artist"]["image"] = Image.objects.create( + **validated_data["inspiring_artist"]["image"] + ) activity.inspiring_artist = InspiringArtist.objects.create( - **validated_data['inspiring_artist']) - - if(activity.materials_used_image is not None or 'materials_used_image' in validated_data): - if (activity.materials_used_image is None): + **validated_data["inspiring_artist"] + ) + + if ( + activity.materials_used_image is not None + or "materials_used_image" in validated_data + ): + if activity.materials_used_image is None: activity.materials_used_image = Image.objects.create( - **validated_data['materials_used_image']) + **validated_data["materials_used_image"] + ) else: - if('materials_used_image' in validated_data): - image = {**validated_data['materials_used_image']} - activity.materials_used_image.file_url = image['file_url'] - activity.materials_used_image.public_id = image['public_id'] + if "materials_used_image" in validated_data: + image = {**validated_data["materials_used_image"]} + activity.materials_used_image.file_url = image["file_url"] + activity.materials_used_image.public_id = image["public_id"] activity.materials_used_image.save() else: activity.materials_used_image.delete() activity.materials_used_image = None - if 'activity_images' in validated_data: - update_activity_images( - activity, validated_data.pop('activity_images')) - if 'making_steps' in validated_data: - update_making_steps(activity, validated_data.pop('making_steps')) - if 'inspiring_examples' in validated_data: + if "activity_images" in validated_data: + update_activity_images(activity, validated_data.pop("activity_images")) + if "making_steps" in validated_data: + update_making_steps(activity, validated_data.pop("making_steps")) + if "inspiring_examples" in validated_data: update_inspiring_examples( - activity, validated_data.pop('inspiring_examples')) - activity.title = validated_data.get('title') + activity, validated_data.pop("inspiring_examples") + ) + activity.title = validated_data.get("title") + + if validated_data.get("introduction"): + activity.introduction = validated_data.get("introduction") - if validated_data.get('introduction'): - activity.introduction = validated_data.get('introduction') + activity.motivation = validated_data.get("motivation") + activity.facilitation_tips = validated_data.get("facilitation_tips") + activity.learning_goals = validated_data.get("learning_goals") - activity.motivation = validated_data.get('motivation') - activity.facilitation_tips = validated_data.get('facilitation_tips') - activity.learning_goals = validated_data.get('learning_goals') + if validated_data.get("publish"): + activity.publish = validated_data.get("publish") - if validated_data.get('publish'): - activity.publish = validated_data.get('publish') + if validated_data.get("materials_used"): + activity.materials_used = validated_data.get("materials_used") - if validated_data.get('materials_used'): - activity.materials_used = validated_data.get('materials_used') - - activity.video = validated_data.get('video') + activity.video = validated_data.get("video") activity.save() return activity diff --git a/zubhub_backend/zubhub/activities/signals.py b/zubhub_backend/zubhub/activities/signals.py new file mode 100644 index 000000000..b5a16ccbe --- /dev/null +++ b/zubhub_backend/zubhub/activities/signals.py @@ -0,0 +1,107 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db.models.signals import m2m_changed, post_delete, pre_delete, pre_save +from django.dispatch import receiver +from projects.tasks import delete_file_task + +from .models import ( + Activity, + ActivityImage, + ActivityMakingStep, + Image, + InspiringArtist, + InspiringExample, +) + + +@receiver(pre_save, sender=Image) +def image_to_be_saved(sender, instance, **kwargs): + if not instance.id: + return + + prev = sender.objects.filter(id=instance.id) + if prev.count() < 1: + return + + try: + if prev[0].file_url != instance.file_url: + delete_file_task.delay(prev[0].file_url) + except ObjectDoesNotExist: + pass # ignore if image has already been deleted + + +@receiver(post_delete, sender=Image) +def image_deleted(sender, instance, **kwargs): + delete_file_task.delay(instance.file_url) + + +@receiver(m2m_changed, sender=ActivityMakingStep.image.through) +def activity_step_many2many_changed(sender, instance, action, **kwargs): + if action in ["pre_remove", "pre_clear"]: + for image in instance.image.all(): + image.delete() + + +@receiver(pre_delete, sender=ActivityMakingStep) +def activity_step_to_be_deleted(sender, instance, **kwargs): + for image in instance.image.all(): + image.delete() + + +@receiver(pre_save, sender=ActivityImage) +@receiver(pre_save, sender=InspiringExample) +@receiver(pre_save, sender=InspiringArtist) +def instance_to_be_saved(sender, instance, **kwargs): + if not instance.id: + return + + prev = sender.objects.filter(id=instance.id) + if prev.count() < 1: + return + + try: + if prev[0].image.id != instance.image.id: + prev[0].image.delete() + except ObjectDoesNotExist: + pass # ignore if image has already been deleted + + +@receiver(post_delete, sender=ActivityImage) +@receiver(post_delete, sender=InspiringExample) +@receiver(post_delete, sender=InspiringArtist) +def instance_deleted(sender, instance, **kwargs): + try: + instance.image.delete() + except sender.DoesNotExist: + pass # ignore if image has already been deleted + except ObjectDoesNotExist: + pass # ignore if image has already been deleted + + +@receiver(pre_save, sender=Activity) +def activity_to_be_saved(sender, instance, **kwargs): + if not instance.id: + return + + prev = sender.objects.filter(id=instance.id) + if prev.count() < 1: + return + + try: + if prev[0].materials_used_image.id != instance.materials_used_image.id: + prev[0].materials_used_image.delete() + except ObjectDoesNotExist: + pass # ignore if image has already been deleted + except AttributeError: + pass # ignore if image has already been deleted + + if prev[0].video != instance.video: + delete_file_task.delay(prev[0].video) + + +@receiver(post_delete, sender=Activity) +def activity_deleted(sender, instance, **kwargs): + try: + instance.materials_used_image.delete() + except ObjectDoesNotExist: + pass # ignore if image has already been deleted + delete_file_task.delay(instance.video)