diff --git a/files/migrations/0018_embedmediacourse.py b/files/migrations/0018_embedmediacourse.py new file mode 100644 index 00000000..5f232d5e --- /dev/null +++ b/files/migrations/0018_embedmediacourse.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.6 on 2026-04-27 17:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0017_mediapermission_source'), + ] + + operations = [ + migrations.CreateModel( + name='EmbedMediaCourse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='embedded_media', to='files.category')), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='embed_courses', to='files.media')), + ], + options={ + 'unique_together': {('media', 'category')}, + }, + ), + ] diff --git a/files/models/__init__.py b/files/models/__init__.py index f1c2362f..d9a3e825 100644 --- a/files/models/__init__.py +++ b/files/models/__init__.py @@ -3,7 +3,7 @@ from .category import Category, Tag # noqa: F401 from .comment import Comment # noqa: F401 from .encoding import EncodeProfile, Encoding # noqa: F401 from .license import License # noqa: F401 -from .media import Media, MediaPermission # noqa: F401 +from .media import EmbedMediaCourse, Media, MediaPermission # noqa: F401 from .page import Page, TinyMCEMedia # noqa: F401 from .playlist import Playlist, PlaylistMedia # noqa: F401 from .rating import Rating, RatingCategory # noqa: F401 diff --git a/files/models/media.py b/files/models/media.py index e6a70ed3..27531527 100644 --- a/files/models/media.py +++ b/files/models/media.py @@ -1002,6 +1002,29 @@ class MediaPermission(models.Model): return f"{self.user.username} - {self.media.title} ({self.permission})" +class EmbedMediaCourse(models.Model): + """ + Records that a user shared a media item into a course during an LTI session. + + This is a pure audit/tracking table used by the course cleanup bulk action to + identify which MediaPermission records were created via LTI embedding and should + be removed when the course is cleaned up. + + It does NOT add the media to the category (Media.category M2M is untouched), + so no m2m_changed signals fire and no category counts are affected. + """ + + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='embed_courses') + category = models.ForeignKey('Category', on_delete=models.CASCADE, related_name='embedded_media') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('media', 'category') + + def __str__(self): + return f"{self.media.title} in {self.category.title}" + + @receiver(post_save, sender=Media) def media_save(sender, instance, created, **kwargs): # media_file path is not set correctly until mode is saved diff --git a/files/views/media.py b/files/views/media.py index 38b7482b..bd8fc96a 100644 --- a/files/views/media.py +++ b/files/views/media.py @@ -36,6 +36,7 @@ from ..methods import ( from ..models import ( Category, Comment, + EmbedMediaCourse, EncodeProfile, Media, MediaPermission, @@ -743,7 +744,7 @@ class MediaBulkUserActions(APIView): def _handle_course_cleanup(self, request, media_ids): category_uids = request.data.get('category_uids', []) remove_permissions = request.data.get('remove_permissions', False) - remove_tags = request.data.get('remove_tags', False) + remove_comments = request.data.get('remove_comments', False) apply_to_all = request.data.get('apply_to_all', False) if not category_uids: @@ -764,24 +765,33 @@ class MediaBulkUserActions(APIView): # All users who are members of any group linked to this category group_users = User.objects.filter(rbac_groups__in=category.rbac_groups.all()).distinct() - course_tag = Tag.objects.filter(title=category.title[:100]).first() if remove_tags else None + # Get media explicitly embedded into this course via LTI + embed_qs = EmbedMediaCourse.objects.filter(category=category) + embedded_media_ids = list(embed_qs.values_list('media_id', flat=True)) all_course_media = Media.objects.filter(category=category) if has_media: if remove_permissions: MediaPermission.objects.filter(media__in=selected_media, user__in=group_users).delete() - if remove_tags and course_tag: - for m in selected_media: - m.tags.remove(course_tag) + # Delete EmbedMediaCourse records and owner MediaPermissions for embedded media + selected_embedded = embed_qs.filter(media__in=selected_media) + selected_embedded_media_ids = list(selected_embedded.values_list('media_id', flat=True)) + selected_embedded.delete() + MediaPermission.objects.filter(media_id__in=selected_embedded_media_ids).delete() + if remove_comments: + Comment.objects.filter(media__in=selected_media).delete() if apply_to_all: other_course_media = all_course_media.exclude(friendly_token__in=media_ids) if remove_permissions: MediaPermission.objects.filter(media__in=other_course_media, user__in=group_users).delete() - if remove_tags and course_tag: - for m in other_course_media: - m.tags.remove(course_tag) + other_embedded = embed_qs.filter(media__in=other_course_media) + other_embedded_media_ids = list(other_embedded.values_list('media_id', flat=True)) + other_embedded.delete() + MediaPermission.objects.filter(media_id__in=other_embedded_media_ids).delete() + if remove_comments: + Comment.objects.filter(media__in=other_course_media).delete() for m in other_course_media: m.category.remove(category) @@ -790,9 +800,10 @@ class MediaBulkUserActions(APIView): else: if remove_permissions: MediaPermission.objects.filter(media__in=all_course_media, user__in=group_users).delete() - if remove_tags and course_tag: - for m in all_course_media: - m.tags.remove(course_tag) + MediaPermission.objects.filter(media_id__in=embedded_media_ids).delete() + embed_qs.delete() + if remove_comments: + Comment.objects.filter(media__in=all_course_media).delete() for m in all_course_media: m.category.remove(category) @@ -1241,4 +1252,11 @@ class MediaShare(APIView): defaults={'owner_user': request.user, 'permission': 'owner'}, ) + lti_session = request.session.get('lti_session', {}) + context_id = lti_session.get('context_id') + if context_id: + category = Category.objects.filter(lti_context_id=context_id, is_rbac_category=True).first() + if category: + EmbedMediaCourse.objects.get_or_create(media=media, category=category) + return Response({'status': 'ok'}) diff --git a/frontend/src/static/js/components/BulkActionCourseCleanupModal.tsx b/frontend/src/static/js/components/BulkActionCourseCleanupModal.tsx index 217983ca..20d02c9b 100644 --- a/frontend/src/static/js/components/BulkActionCourseCleanupModal.tsx +++ b/frontend/src/static/js/components/BulkActionCourseCleanupModal.tsx @@ -29,7 +29,7 @@ export const BulkActionCourseCleanupModal: React.FC([]); const [coursesToCleanup, setCoursesToCleanup] = useState([]); const [removePermissions, setRemovePermissions] = useState(false); - const [removeTags, setRemoveTags] = useState(false); + const [removeComments, setRemoveComments] = useState(false); const [applyToAll, setApplyToAll] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isProcessing, setIsProcessing] = useState(false); @@ -41,7 +41,7 @@ export const BulkActionCourseCleanupModal: React.FC c.uid), remove_permissions: removePermissions, - remove_tags: removeTags, + remove_comments: removeComments, apply_to_all: applyToAll, }), }); @@ -198,10 +198,10 @@ export const BulkActionCourseCleanupModal: React.FC setRemoveTags(e.target.checked)} + checked={removeComments} + onChange={(e) => setRemoveComments(e.target.checked)} /> - {translateString('Remove course tags')} + {translateString('Remove Comments')} {hasMediaSelected && (