From d7fc552230a486f62bfd7f0beebd2ae770f29479 Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Sat, 18 Apr 2026 15:07:51 +0300 Subject: [PATCH] a --- cms/version.py | 2 +- files/views/media.py | 68 ++++++++++++++++++- .../js/components/BulkActionsDropdown.tsx | 14 +++- .../js/components/BulkActionsModals.jsx | 21 ++++++ .../static/js/components/MediaListWrapper.tsx | 4 +- .../src/static/js/pages/ProfileMediaPage.js | 5 ++ .../static/js/pages/ProfileSharedByMePage.js | 5 ++ .../static/js/utils/hooks/useBulkActions.js | 43 +++++++++++- static/css/_commons.css | 3 +- static/js/_commons.js | 2 +- static/js/categories.js | 2 +- static/js/featured.js | 2 +- static/js/latest.js | 2 +- static/js/members.js | 2 +- static/js/profile-about.js | 2 +- static/js/profile-media.js | 2 +- static/js/profile-playlists.js | 2 +- static/js/profile-shared-by-me.js | 2 +- static/js/profile-shared-with-me.js | 2 +- static/js/recommended.js | 2 +- static/js/search.js | 2 +- static/js/tags.js | 2 +- 22 files changed, 168 insertions(+), 23 deletions(-) diff --git a/cms/version.py b/cms/version.py index 3238a1db..688d2efe 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "8.99" +VERSION = "8.991" diff --git a/files/views/media.py b/files/views/media.py index 6ee65064..20ee9bda 100644 --- a/files/views/media.py +++ b/files/views/media.py @@ -357,6 +357,7 @@ class MediaBulkUserActions(APIView): "remove_from_category", "add_tags", "remove_tags", + "course_cleanup", ], ), 'playlist_ids': openapi.Schema( @@ -404,12 +405,15 @@ class MediaBulkUserActions(APIView): media_ids = request.data.get('media_ids', []) action = request.data.get('action') - if not media_ids: - return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST) - if not action: return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST) + if action == "course_cleanup": + return self._handle_course_cleanup(request, media_ids) + + if not media_ids: + return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST) + media = Media.objects.filter(user=request.user, friendly_token__in=media_ids) if not media: @@ -727,6 +731,64 @@ class MediaBulkUserActions(APIView): else: return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST) + 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) + apply_to_all = request.data.get('apply_to_all', False) + + if not category_uids: + return Response({"detail": "category_uids is required"}, status=status.HTTP_400_BAD_REQUEST) + + categories = Category.objects.filter(uid__in=category_uids) + if not categories.exists(): + return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST) + + valid_categories = [cat for cat in categories if request.user.has_contributor_access_to_category(cat)] + if not valid_categories: + return Response({"detail": "No contributor access to specified categories"}, status=status.HTTP_403_FORBIDDEN) + + has_media = bool(media_ids) + selected_media = Media.objects.filter(user=request.user, friendly_token__in=media_ids) if has_media else Media.objects.none() + + for category in valid_categories: + # 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 + + 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) + + 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) + for m in other_course_media: + m.category.remove(category) + + for m in selected_media: + m.category.remove(category) + 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) + for m in all_course_media: + m.category.remove(category) + + return Response({"detail": "Course cleanup completed successfully"}) + class MediaDetail(APIView): """ diff --git a/frontend/src/static/js/components/BulkActionsDropdown.tsx b/frontend/src/static/js/components/BulkActionsDropdown.tsx index d6aefecc..ba558b2c 100644 --- a/frontend/src/static/js/components/BulkActionsDropdown.tsx +++ b/frontend/src/static/js/components/BulkActionsDropdown.tsx @@ -6,12 +6,14 @@ import { inEmbeddedApp } from '../utils/helpers/embeddedApp'; interface BulkActionsDropdownProps { selectedCount: number; onActionSelect: (action: string) => void; + hasContributorCourses?: boolean; } interface BulkAction { value: string; label: string; enabled: boolean; + allowsNoSelection?: boolean; } interface BulkActionGroup { @@ -19,7 +21,7 @@ interface BulkActionGroup { actions: BulkAction[]; } -export const BulkActionsDropdown: React.FC = ({ selectedCount, onActionSelect }) => { +export const BulkActionsDropdown: React.FC = ({ selectedCount, onActionSelect, hasContributorCourses = false }) => { const isLmsMode = inEmbeddedApp(); const BULK_ACTION_GROUPS: BulkActionGroup[] = [ @@ -56,18 +58,24 @@ export const BulkActionsDropdown: React.FC = ({ select { value: 'change-owner', label: translateString('Change Owner'), enabled: true }, { value: 'copy-media', label: translateString('Copy Media'), enabled: true }, { value: 'delete-media', label: translateString('Delete Media'), enabled: true }, + ...(isLmsMode && hasContributorCourses + ? [{ value: 'course-cleanup', label: translateString('Course Cleanup'), enabled: true, allowsNoSelection: true }] + : []), ], }, ]; const noSelection = selectedCount === 0; + const allActions = BULK_ACTION_GROUPS.flatMap((g) => g.actions); + const handleChange = (event: React.ChangeEvent) => { const value = event.target.value; if (!value) return; - if (noSelection) { + const actionDef = allActions.find((a) => a.value === value); + if (noSelection && !actionDef?.allowsNoSelection) { event.target.value = ''; return; } @@ -95,7 +103,7 @@ export const BulkActionsDropdown: React.FC = ({ select {BULK_ACTION_GROUPS.map((group) => ( {group.actions.map((action) => ( - ))} diff --git a/frontend/src/static/js/components/BulkActionsModals.jsx b/frontend/src/static/js/components/BulkActionsModals.jsx index e1227d68..f4d3de02 100644 --- a/frontend/src/static/js/components/BulkActionsModals.jsx +++ b/frontend/src/static/js/components/BulkActionsModals.jsx @@ -7,6 +7,7 @@ import { BulkActionChangeOwnerModal } from './BulkActionChangeOwnerModal'; import { BulkActionPublishStateModal } from './BulkActionPublishStateModal'; import { BulkActionCategoryModal } from './BulkActionCategoryModal'; import { BulkActionTagModal } from './BulkActionTagModal'; +import { BulkActionCourseCleanupModal } from './BulkActionCourseCleanupModal'; /** * Renders all bulk action modals @@ -58,6 +59,12 @@ export function BulkActionsModals({ onTagModalSuccess, onTagModalError, + // Course cleanup modal props + showCourseCleanupModal, + onCourseCleanupModalCancel, + onCourseCleanupModalSuccess, + onCourseCleanupModalError, + // Common props csrfToken, @@ -131,6 +138,15 @@ export function BulkActionsModals({ csrfToken={csrfToken} /> + + {showNotification && (
void; onDeselectAll?: () => void; showAddMediaButton?: boolean; + hasContributorCourses?: boolean; } export const MediaListWrapper: React.FC = ({ @@ -38,6 +39,7 @@ export const MediaListWrapper: React.FC = ({ onSelectAll = () => {}, onDeselectAll = () => {}, showAddMediaButton = false, + hasContributorCourses = false, }) => { const [popupContentRef, PopupContent, PopupTrigger] = usePopup() as [any, any, any]; @@ -63,7 +65,7 @@ export const MediaListWrapper: React.FC = ({ return (
- +