This commit is contained in:
Markos Gogoulos
2026-04-18 15:07:51 +03:00
parent 89c4fe1b2b
commit d7fc552230
22 changed files with 168 additions and 23 deletions
+1 -1
View File
@@ -1 +1 @@
VERSION = "8.99"
VERSION = "8.991"
+65 -3
View File
@@ -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):
"""
@@ -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<BulkActionsDropdownProps> = ({ selectedCount, onActionSelect }) => {
export const BulkActionsDropdown: React.FC<BulkActionsDropdownProps> = ({ selectedCount, onActionSelect, hasContributorCourses = false }) => {
const isLmsMode = inEmbeddedApp();
const BULK_ACTION_GROUPS: BulkActionGroup[] = [
@@ -56,18 +58,24 @@ export const BulkActionsDropdown: React.FC<BulkActionsDropdownProps> = ({ 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<HTMLSelectElement>) => {
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<BulkActionsDropdownProps> = ({ select
{BULK_ACTION_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.actions.map((action) => (
<option key={action.value} value={action.value} disabled={noSelection || !action.enabled}>
<option key={action.value} value={action.value} disabled={(!action.allowsNoSelection && noSelection) || !action.enabled}>
{action.label}
</option>
))}
@@ -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}
/>
<BulkActionCourseCleanupModal
isOpen={showCourseCleanupModal}
selectedMediaIds={selectedMediaIds}
onCancel={onCourseCleanupModalCancel}
onSuccess={onCourseCleanupModalSuccess}
onError={onCourseCleanupModalError}
csrfToken={csrfToken}
/>
{showNotification && (
<div
style={{
@@ -193,6 +209,11 @@ BulkActionsModals.propTypes = {
onTagModalSuccess: PropTypes.func.isRequired,
onTagModalError: PropTypes.func.isRequired,
showCourseCleanupModal: PropTypes.bool.isRequired,
onCourseCleanupModalCancel: PropTypes.func.isRequired,
onCourseCleanupModalSuccess: PropTypes.func.isRequired,
onCourseCleanupModalError: PropTypes.func.isRequired,
csrfToken: PropTypes.string.isRequired,
showNotification: PropTypes.bool.isRequired,
@@ -22,6 +22,7 @@ interface MediaListWrapperProps {
onSelectAll?: () => void;
onDeselectAll?: () => void;
showAddMediaButton?: boolean;
hasContributorCourses?: boolean;
}
export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
@@ -38,6 +39,7 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
onSelectAll = () => {},
onDeselectAll = () => {},
showAddMediaButton = false,
hasContributorCourses = false,
}) => {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup() as [any, any, any];
@@ -63,7 +65,7 @@ export const MediaListWrapper: React.FC<MediaListWrapperProps> = ({
return (
<div className="bulk-actions-container">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} />
<BulkActionsDropdown selectedCount={selectedCount} onActionSelect={onBulkAction} hasContributorCourses={hasContributorCourses} />
<SelectAllCheckbox
totalCount={totalCount}
selectedCount={selectedCount}
@@ -463,6 +463,7 @@ class ProfileMediaPage extends Page {
onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
showAddMediaButton={!isSelectMediaMode && isMediaAuthor}
hasContributorCourses={this.props.bulkActions.hasContributorCourses}
>
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
@@ -530,6 +531,10 @@ class ProfileMediaPage extends Page {
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
onTagModalError={this.props.bulkActions.handleTagModalError}
showCourseCleanupModal={this.props.bulkActions.showCourseCleanupModal}
onCourseCleanupModalCancel={this.props.bulkActions.handleCourseCleanupModalCancel}
onCourseCleanupModalSuccess={this.props.bulkActions.handleCourseCleanupModalSuccess}
onCourseCleanupModalError={this.props.bulkActions.handleCourseCleanupModalError}
/>
) : null,
];
@@ -486,6 +486,7 @@ class ProfileSharedByMePage extends Page {
onBulkAction={this.props.bulkActions.handleBulkAction}
onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll}
hasContributorCourses={this.props.bulkActions.hasContributorCourses}
>
<ProfileMediaFilters
hidden={this.state.hiddenFilters}
@@ -554,6 +555,10 @@ class ProfileSharedByMePage extends Page {
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
onTagModalError={this.props.bulkActions.handleTagModalError}
showCourseCleanupModal={this.props.bulkActions.showCourseCleanupModal}
onCourseCleanupModalCancel={this.props.bulkActions.handleCourseCleanupModalCancel}
onCourseCleanupModalSuccess={this.props.bulkActions.handleCourseCleanupModalSuccess}
onCourseCleanupModalError={this.props.bulkActions.handleCourseCleanupModalError}
/>
) : null,
];
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { translateString } from '../helpers';
import { inEmbeddedApp } from '../helpers/embeddedApp';
/**
* Custom hook for managing bulk actions on media items
@@ -22,6 +23,20 @@ export function useBulkActions() {
const [showPublishStateModal, setShowPublishStateModal] = useState(false);
const [showCategoryModal, setShowCategoryModal] = useState(false);
const [showTagModal, setShowTagModal] = useState(false);
const [showCourseCleanupModal, setShowCourseCleanupModal] = useState(false);
const [hasContributorCourses, setHasContributorCourses] = useState(false);
useEffect(() => {
if (!inEmbeddedApp()) return;
fetch('/api/v1/categories/contributor?lms_courses_only=true')
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const courses = data.results || data;
setHasContributorCourses(Array.isArray(courses) && courses.length > 0);
})
.catch(() => {});
}, []);
// Get CSRF token from cookies
const getCsrfToken = () => {
@@ -95,6 +110,11 @@ export function useBulkActions() {
const handleBulkAction = (action) => {
const selectedCount = selectedMedia.size;
if (action === 'course-cleanup') {
setShowCourseCleanupModal(true);
return;
}
if (selectedCount === 0) {
return;
}
@@ -500,6 +520,22 @@ export function useBulkActions() {
setShowTagModal(false);
};
// Course cleanup modal handlers
const handleCourseCleanupModalCancel = () => {
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalSuccess = (message) => {
showNotificationMessage(message);
clearSelectionAndRefresh();
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalError = (message) => {
showNotificationMessage(message, 'error');
setShowCourseCleanupModal(false);
};
return {
// State
selectedMedia,
@@ -517,6 +553,8 @@ export function useBulkActions() {
showPublishStateModal,
showCategoryModal,
showTagModal,
showCourseCleanupModal,
hasContributorCourses,
// Handlers
handleMediaSelection,
@@ -544,6 +582,9 @@ export function useBulkActions() {
handleTagModalCancel,
handleTagModalSuccess,
handleTagModalError,
handleCourseCleanupModalCancel,
handleCourseCleanupModalSuccess,
handleCourseCleanupModalError,
// Utility
getCsrfToken,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long