From a79acb5ab031ebfa3efbb09c80ce8d2c53110a2f Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Sun, 15 Mar 2026 17:09:51 +0200 Subject: [PATCH] all --- LTI_README.md | 1 + cms/version.py | 2 +- files/views/media.py | 36 ++++++- .../components/profile-page/ProfilePage.scss | 8 ++ .../profile-page/ProfilePagesHeader.js | 39 +++++++ .../search-filters/ProfileMediaSharing.jsx | 101 ++++++++++++++++++ .../src/static/js/pages/ProfileMediaPage.js | 71 ++++++++++++ .../static/js/pages/ProfileSharedByMePage.js | 63 +++++++++++ 8 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 LTI_README.md create mode 100644 frontend/src/static/js/components/search-filters/ProfileMediaSharing.jsx diff --git a/LTI_README.md b/LTI_README.md new file mode 100644 index 00000000..efb831bb --- /dev/null +++ b/LTI_README.md @@ -0,0 +1 @@ +Django admin → /admin/lti/ltiplatform/ --> Change to: https://YOUR_MOODLE/filter/mediacms/lti_auth.php diff --git a/cms/version.py b/cms/version.py index 44c7dd3a..d225d7e8 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "8.37" +VERSION = "8.91" diff --git a/files/views/media.py b/files/views/media.py index 1e1e7b95..b5beaed3 100644 --- a/files/views/media.py +++ b/files/views/media.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from django.conf import settings from django.contrib.postgres.search import SearchQuery -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q, prefetch_related_objects from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -113,6 +113,8 @@ class MediaList(APIView): upload_date = params.get('upload_date', '').strip() duration = params.get('duration', '').strip() publish_state = params.get('publish_state', '').strip() + shared_user = params.get('shared_user', '').strip() + shared_group = params.get('shared_group', '').strip() query = params.get("q", "").strip().lower() parsed_combined = False @@ -153,6 +155,7 @@ class MediaList(APIView): gte = datetime(year, 1, 1) already_sorted = False + include_sharing_info = False pagination_class = api_settings.DEFAULT_PAGINATION_CLASS if show_param == "recommended": @@ -173,6 +176,7 @@ class MediaList(APIView): conditions |= Q(category__in=rbac_categories, user=self.request.user) media = base_queryset.filter(conditions).distinct() + include_sharing_info = True elif show_param == "shared_with_me": if not self.request.user.is_authenticated: media = Media.objects.none() @@ -192,6 +196,8 @@ class MediaList(APIView): user = get_object_or_404(user_queryset, username=author_param) if self.request.user == user or is_mediacms_editor(self.request.user): media = Media.objects.filter(user=user).prefetch_related("user", "tags") + if self.request.user == user: + include_sharing_info = True else: media = self._get_media_queryset(request, user) already_sorted = True @@ -242,6 +248,12 @@ class MediaList(APIView): elif publish_state in ['private', 'public', 'unlisted']: media = media.filter(state=publish_state) + if shared_user and include_sharing_info: + media = media.filter(permissions__user__username=shared_user).distinct() + + if shared_group and include_sharing_info: + media = media.filter(category__is_rbac_category=True, category__rbac_groups__name=shared_group).distinct() + if not already_sorted: media = media.order_by(f"{ordering}{sort_by}") @@ -251,6 +263,15 @@ class MediaList(APIView): page = paginator.paginate_queryset(media, request) + prefetch_related_objects(page, 'tags') + + if include_sharing_info: + prefetch_related_objects( + page, + Prefetch('permissions', queryset=MediaPermission.objects.select_related('user')), + Prefetch('category', queryset=Category.objects.filter(is_rbac_category=True).prefetch_related('rbac_groups'), to_attr='rbac_categories_prefetched'), + ) + serializer = MediaSerializer(page, many=True, context={"request": request}) tags_set = set() @@ -261,6 +282,19 @@ class MediaList(APIView): response = paginator.get_paginated_response(serializer.data) response.data['tags'] = tags + + if include_sharing_info: + shared_users = {} + shared_groups = {} + for media_obj in page: + for perm in media_obj.permissions.all(): + shared_users[perm.user.username] = {"username": perm.user.username, "name": perm.user.name or perm.user.username} + for cat in getattr(media_obj, 'rbac_categories_prefetched', []): + for group in cat.rbac_groups.all(): + shared_groups[group.name] = {"name": group.name} + response.data['shared_users'] = list(shared_users.values()) + response.data['shared_groups'] = list(shared_groups.values()) + return response @swagger_auto_schema( diff --git a/frontend/src/static/js/components/profile-page/ProfilePage.scss b/frontend/src/static/js/components/profile-page/ProfilePage.scss index 8dddb5e5..41a810a1 100644 --- a/frontend/src/static/js/components/profile-page/ProfilePage.scss +++ b/frontend/src/static/js/components/profile-page/ProfilePage.scss @@ -576,6 +576,7 @@ // Ensure icon buttons are visible on mobile &.media-search, &.media-filters-toggle, + &.media-sharing-toggle, &.media-tags-toggle, &.media-sorting-toggle { @media screen and (max-width: 768px) { @@ -901,6 +902,13 @@ $-max-width: $-hor-spaces + ( 2 * $item-width ) - 1; } } +.mi-sharing-filter-options { + > .active button, + > * button:hover { + background-color: #3b82f6 !important; + } +} + $-hor-spaces: 2 * $side-empty-space; $-max-width: $-hor-spaces + ( 2 * $item-width ) - 1; diff --git a/frontend/src/static/js/components/profile-page/ProfilePagesHeader.js b/frontend/src/static/js/components/profile-page/ProfilePagesHeader.js index f145c951..b480f895 100644 --- a/frontend/src/static/js/components/profile-page/ProfilePagesHeader.js +++ b/frontend/src/static/js/components/profile-page/ProfilePagesHeader.js @@ -491,6 +491,39 @@ class NavMenuInlineTabs extends React.PureComponent { ) : null} + {this.props.onToggleSharingClick && + ['media', 'shared_by_me'].includes(this.props.type) ? ( +
  • + + + people + + {this.props.hasActiveSharing ? ( + + ) : null} + +
  • + ) : null} {this.props.onToggleTagsClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
  • @@ -570,9 +603,11 @@ NavMenuInlineTabs.propTypes = { type: PropTypes.string.isRequired, onQueryChange: PropTypes.func, onToggleFiltersClick: PropTypes.func, + onToggleSharingClick: PropTypes.func, onToggleTagsClick: PropTypes.func, onToggleSortingClick: PropTypes.func, hasActiveFilters: PropTypes.bool, + hasActiveSharing: PropTypes.bool, hasActiveTags: PropTypes.bool, hasActiveSort: PropTypes.bool, }; @@ -776,9 +811,11 @@ export default function ProfilePagesHeader(props) { type={props.type} onQueryChange={props.onQueryChange} onToggleFiltersClick={props.onToggleFiltersClick} + onToggleSharingClick={userIsAuthor ? props.onToggleSharingClick : undefined} onToggleTagsClick={props.onToggleTagsClick} onToggleSortingClick={props.onToggleSortingClick} hasActiveFilters={props.hasActiveFilters} + hasActiveSharing={props.hasActiveSharing} hasActiveTags={props.hasActiveTags} hasActiveSort={props.hasActiveSort} /> @@ -792,9 +829,11 @@ ProfilePagesHeader.propTypes = { type: PropTypes.string.isRequired, onQueryChange: PropTypes.func, onToggleFiltersClick: PropTypes.func, + onToggleSharingClick: PropTypes.func, onToggleTagsClick: PropTypes.func, onToggleSortingClick: PropTypes.func, hasActiveFilters: PropTypes.bool, + hasActiveSharing: PropTypes.bool, hasActiveTags: PropTypes.bool, hasActiveSort: PropTypes.bool, }; diff --git a/frontend/src/static/js/components/search-filters/ProfileMediaSharing.jsx b/frontend/src/static/js/components/search-filters/ProfileMediaSharing.jsx new file mode 100644 index 00000000..1fc375cc --- /dev/null +++ b/frontend/src/static/js/components/search-filters/ProfileMediaSharing.jsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { PageStore } from '../../utils/stores/'; +import { FilterOptions } from '../_shared'; +import { translateString } from '../../utils/helpers/'; +import '../management-table/ManageItemList-filters.scss'; + +export function ProfileMediaSharing(props) { + const [isHidden, setIsHidden] = useState(props.hidden); + + const containerRef = useRef(null); + const innerContainerRef = useRef(null); + + function onWindowResize() { + if (!isHidden && containerRef.current && innerContainerRef.current) { + containerRef.current.style.height = 24 + innerContainerRef.current.offsetHeight + 'px'; + } + } + + useEffect(() => { + setIsHidden(props.hidden); + onWindowResize(); + }, [props.hidden, props.sharedUsers, props.sharedGroups]); + + useEffect(() => { + PageStore.on('window_resize', onWindowResize); + return () => PageStore.removeListener('window_resize', onWindowResize); + }, []); + + function onUserSelect(ev) { + const username = ev.currentTarget.getAttribute('value'); + const newValue = (username === 'all' || username === props.selectedSharingValue) ? null : username; + props.onSharingSelect(newValue ? 'user' : null, newValue); + } + + function onGroupSelect(ev) { + const name = ev.currentTarget.getAttribute('value'); + const newValue = (name === 'all' || name === props.selectedSharingValue) ? null : name; + props.onSharingSelect(newValue ? 'group' : null, newValue); + } + + const hasUsers = props.sharedUsers && props.sharedUsers.length > 0; + const hasGroups = props.sharedGroups && props.sharedGroups.length > 0; + + const usersOptions = [ + { id: 'all', title: translateString('All') }, + ...(props.sharedUsers || []).map((u) => ({ id: u.username, title: u.name })), + ]; + const groupsOptions = [ + { id: 'all', title: translateString('All') }, + ...(props.sharedGroups || []).map((g) => ({ id: g.name, title: g.name })), + ]; + + const selectedUser = props.selectedSharingType === 'user' ? props.selectedSharingValue : 'all'; + const selectedGroup = props.selectedSharingType === 'group' ? props.selectedSharingValue : 'all'; + + return ( +
    +
    + {hasUsers ? ( +
    +
    {translateString('SHARED WITH USERS')}
    +
    + +
    +
    + ) : null} + {hasGroups ? ( +
    +
    {translateString('SHARED WITH GROUPS')}
    +
    + +
    +
    + ) : null} + {!hasUsers && !hasGroups ? ( +
    +
    {translateString('NOT SHARED WITH ANYONE')}
    +
    + ) : null} +
    +
    + ); +} + +ProfileMediaSharing.propTypes = { + hidden: PropTypes.bool, + sharedUsers: PropTypes.array, + sharedGroups: PropTypes.array, + onSharingSelect: PropTypes.func, + selectedSharingType: PropTypes.string, + selectedSharingValue: PropTypes.string, +}; + +ProfileMediaSharing.defaultProps = { + hidden: false, + sharedUsers: [], + sharedGroups: [], + selectedSharingType: null, + selectedSharingValue: null, +}; diff --git a/frontend/src/static/js/pages/ProfileMediaPage.js b/frontend/src/static/js/pages/ProfileMediaPage.js index f6901835..9c662386 100755 --- a/frontend/src/static/js/pages/ProfileMediaPage.js +++ b/frontend/src/static/js/pages/ProfileMediaPage.js @@ -11,6 +11,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA import { BulkActionsModals } from '../components/BulkActionsModals'; import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters'; import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags'; +import { ProfileMediaSharing } from '../components/search-filters/ProfileMediaSharing'; import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting'; import { withBulkActions } from '../utils/hoc/withBulkActions'; @@ -35,10 +36,15 @@ class ProfileMediaPage extends Page { hiddenFilters: true, hiddenTags: true, hiddenSorting: true, + hiddenSharing: true, filterArgs: '', availableTags: [], selectedTag: 'all', selectedSort: 'date_added_desc', + sharedUsers: [], + sharedGroups: [], + selectedSharingType: null, + selectedSharingValue: null, }; this.authorDataLoad = this.authorDataLoad.bind(this); @@ -49,9 +55,11 @@ class ProfileMediaPage extends Page { this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this); this.onToggleTagsClick = this.onToggleTagsClick.bind(this); this.onToggleSortingClick = this.onToggleSortingClick.bind(this); + this.onToggleSharingClick = this.onToggleSharingClick.bind(this); this.onFiltersUpdate = this.onFiltersUpdate.bind(this); this.onTagSelect = this.onTagSelect.bind(this); this.onSortSelect = this.onSortSelect.bind(this); + this.onSharingSelect = this.onSharingSelect.bind(this); this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this); ProfilePageStore.on('load-author-data', this.authorDataLoad); @@ -178,6 +186,7 @@ class ProfileMediaPage extends Page { hiddenFilters: !this.state.hiddenFilters, hiddenTags: true, hiddenSorting: true, + hiddenSharing: true, }); } @@ -186,6 +195,7 @@ class ProfileMediaPage extends Page { hiddenFilters: true, hiddenTags: !this.state.hiddenTags, hiddenSorting: true, + hiddenSharing: true, }); } @@ -194,6 +204,16 @@ class ProfileMediaPage extends Page { hiddenFilters: true, hiddenTags: true, hiddenSorting: !this.state.hiddenSorting, + hiddenSharing: true, + }); + } + + onToggleSharingClick() { + this.setState({ + hiddenFilters: true, + hiddenTags: true, + hiddenSorting: true, + hiddenSharing: !this.state.hiddenSharing, }); } @@ -214,6 +234,8 @@ class ProfileMediaPage extends Page { : null, sort_by: this.state.selectedSort, tag: tag, + sharing_type: this.state.selectedSharingType, + sharing_value: this.state.selectedSharingValue, }); }); } @@ -235,6 +257,31 @@ class ProfileMediaPage extends Page { : null, sort_by: sortOption, tag: this.state.selectedTag, + sharing_type: this.state.selectedSharingType, + sharing_value: this.state.selectedSharingValue, + }); + }); + } + + onSharingSelect(type, value) { + this.setState({ selectedSharingType: type, selectedSharingValue: value }, () => { + this.onFiltersUpdate({ + media_type: this.state.filterArgs.includes('media_type') + ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] + : null, + upload_date: this.state.filterArgs.includes('upload_date') + ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] + : null, + duration: this.state.filterArgs.includes('duration') + ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] + : null, + publish_state: this.state.filterArgs.includes('publish_state') + ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] + : null, + sort_by: this.state.selectedSort, + tag: this.state.selectedTag, + sharing_type: type, + sharing_value: value, }); }); } @@ -248,6 +295,8 @@ class ProfileMediaPage extends Page { sort_by: null, ordering: null, t: null, + shared_user: null, + shared_group: null, }; switch (updatedArgs.media_type) { @@ -306,6 +355,12 @@ class ProfileMediaPage extends Page { args.t = updatedArgs.tag; } + if (updatedArgs.sharing_type === 'user' && updatedArgs.sharing_value) { + args.shared_user = updatedArgs.sharing_value; + } else if (updatedArgs.sharing_type === 'group' && updatedArgs.sharing_value) { + args.shared_group = updatedArgs.sharing_value; + } + const newArgs = []; for (let arg in args) { @@ -353,6 +408,12 @@ class ProfileMediaPage extends Page { .filter((tag) => tag); this.setState({ availableTags: tags }); } + if (responseData && responseData.shared_users !== undefined) { + this.setState({ + sharedUsers: responseData.shared_users || [], + sharedGroups: responseData.shared_groups || [], + }); + } } pageContent() { @@ -381,9 +442,11 @@ class ProfileMediaPage extends Page { onToggleFiltersClick={this.onToggleFiltersClick} onToggleTagsClick={this.onToggleTagsClick} onToggleSortingClick={this.onToggleSortingClick} + onToggleSharingClick={this.onToggleSharingClick} hasActiveFilters={hasActiveFilters} hasActiveTags={hasActiveTags} hasActiveSort={hasActiveSort} + hasActiveSharing={!!this.state.selectedSharingValue} hideChannelBanner={inEmbeddedApp()} /> ) : null, @@ -414,6 +477,14 @@ class ProfileMediaPage extends Page { onTagSelect={this.onTagSelect} />