course remove

This commit is contained in:
Markos Gogoulos
2026-04-27 20:44:01 +03:00
parent a535a0a477
commit 38a6d7e62a
6 changed files with 86 additions and 19 deletions
+26
View File
@@ -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')},
},
),
]
+1 -1
View File
@@ -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
+23
View File
@@ -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
+29 -11
View File
@@ -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'})
@@ -29,7 +29,7 @@ export const BulkActionCourseCleanupModal: React.FC<BulkActionCourseCleanupModal
const [availableCourses, setAvailableCourses] = useState<Course[]>([]);
const [coursesToCleanup, setCoursesToCleanup] = useState<Course[]>([]);
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<BulkActionCourseCleanupModal
setAvailableCourses([]);
setCoursesToCleanup([]);
setRemovePermissions(false);
setRemoveTags(false);
setRemoveComments(false);
setApplyToAll(false);
}
}, [isOpen, selectedMediaIds.join(',')]);
@@ -98,7 +98,7 @@ export const BulkActionCourseCleanupModal: React.FC<BulkActionCourseCleanupModal
media_ids: selectedMediaIds,
category_uids: coursesToCleanup.map((c) => c.uid),
remove_permissions: removePermissions,
remove_tags: removeTags,
remove_comments: removeComments,
apply_to_all: applyToAll,
}),
});
@@ -198,10 +198,10 @@ export const BulkActionCourseCleanupModal: React.FC<BulkActionCourseCleanupModal
<label className="course-cleanup-checkbox">
<input
type="checkbox"
checked={removeTags}
onChange={(e) => setRemoveTags(e.target.checked)}
checked={removeComments}
onChange={(e) => setRemoveComments(e.target.checked)}
/>
<span>{translateString('Remove course tags')}</span>
<span>{translateString('Remove Comments')}</span>
</label>
{hasMediaSelected && (
<label className="course-cleanup-checkbox">
File diff suppressed because one or more lines are too long