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}
/>
+
{
+ this.onFiltersUpdate({
+ media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
+ upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
+ duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
+ publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
+ sort_by: this.state.selectedSort,
+ tag: this.state.selectedTag,
+ sharing_type: type,
+ sharing_value: value,
});
});
}
@@ -231,6 +270,8 @@ class ProfileSharedByMePage extends Page {
sort_by: null,
ordering: null,
t: null,
+ shared_user: null,
+ shared_group: null,
};
switch (updatedArgs.media_type) {
@@ -292,6 +333,12 @@ class ProfileSharedByMePage 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) {
@@ -343,6 +390,12 @@ class ProfileSharedByMePage 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 || [],
+ });
+ }
}
handleMediaSelection(mediaId, isSelected) {
@@ -413,9 +466,11 @@ class ProfileSharedByMePage extends Page {
onToggleFiltersClick={this.onToggleFiltersClick}
onToggleTagsClick={this.onToggleTagsClick}
onToggleSortingClick={this.onToggleSortingClick}
+ onToggleSharingClick={this.onToggleSharingClick}
hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
+ hasActiveSharing={!!this.state.selectedSharingValue}
hideChannelBanner={inEmbeddedApp()}
/>
) : null,
@@ -443,6 +498,14 @@ class ProfileSharedByMePage extends Page {
onTagSelect={this.onTagSelect}
/>
+