Bulk actions support (#1418)

This commit is contained in:
Markos Gogoulos
2025-11-11 11:32:54 +02:00
committed by GitHub
parent 2a0cb977f2
commit e80590a3aa
160 changed files with 14100 additions and 1797 deletions

View File

@@ -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 Q
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
@@ -33,7 +33,15 @@ from ..methods import (
show_related_media,
update_user_ratings,
)
from ..models import EncodeProfile, Media, MediaPermission, Playlist, PlaylistMedia
from ..models import (
Category,
EncodeProfile,
Media,
MediaPermission,
Playlist,
PlaylistMedia,
Tag,
)
from ..serializers import MediaSearchSerializer, MediaSerializer, SingleMediaSerializer
from ..stop_words import STOP_WORDS
from ..tasks import save_user_action
@@ -61,15 +69,13 @@ class MediaList(APIView):
if user:
base_filters &= Q(user=user)
base_queryset = Media.objects.prefetch_related("user")
base_queryset = Media.objects.prefetch_related("user", "tags")
if not request.user.is_authenticated:
return base_queryset.filter(base_filters).order_by("-add_date")
return base_queryset.filter(base_filters)
# Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
conditions = base_filters
# Add user permissions
permission_filter = {'user': request.user}
if user:
permission_filter['owner_user'] = user
@@ -80,7 +86,6 @@ class MediaList(APIView):
perm_conditions &= Q(user=user)
conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories)
@@ -88,10 +93,9 @@ class MediaList(APIView):
rbac_conditions &= Q(user=user)
conditions |= rbac_conditions
return base_queryset.filter(conditions).distinct().order_by("-add_date")[:1000]
return base_queryset.filter(conditions).distinct()
def get(self, request, format=None):
# Show media
# authenticated users can see:
# All listable media (public access)
@@ -100,51 +104,151 @@ class MediaList(APIView):
params = self.request.query_params
show_param = params.get("show", "")
author_param = params.get("author", "").strip()
tag = params.get("t", "").strip()
ordering = params.get("ordering", "").strip()
sort_by = params.get("sort_by", "").strip()
media_type = params.get("media_type", "").strip()
upload_date = params.get('upload_date', '').strip()
duration = params.get('duration', '').strip()
publish_state = params.get('publish_state', '').strip()
query = params.get("q", "").strip().lower()
parsed_combined = False
if sort_by and '_' in sort_by:
parts = sort_by.rsplit('_', 1)
if len(parts) == 2 and parts[1] in ['asc', 'desc']:
field, direction = parts
if field in ["title", "add_date", "edit_date", "views", "likes"]:
sort_by = field
ordering = "" if direction == "asc" else "-"
parsed_combined = True
# Fall back to legacy handling only if we didn't parse a combined option
if not parsed_combined:
sort_by_options = ["title", "add_date", "edit_date", "views", "likes"]
if sort_by not in sort_by_options:
sort_by = "add_date"
if ordering == "asc":
ordering = ""
else:
ordering = "-"
if media_type not in ["video", "image", "audio", "pdf"]:
media_type = None
gte = None
if upload_date:
if upload_date == 'today':
gte = datetime.now().date()
if upload_date == 'this_week':
gte = datetime.now() - timedelta(days=7)
if upload_date == 'this_month':
year = datetime.now().date().year
month = datetime.now().date().month
gte = datetime(year, month, 1)
if upload_date == 'this_year':
year = datetime.now().date().year
gte = datetime(year, 1, 1)
already_sorted = False
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
if show_param == "recommended":
pagination_class = FastPaginationWithoutCount
media = show_recommended_media(request, limit=50)
already_sorted = True
elif show_param == "featured":
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user").order_by("-add_date")
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user", "tags")
elif show_param == "shared_by_me":
if not self.request.user.is_authenticated:
media = Media.objects.none()
else:
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user")
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user", "tags").distinct()
elif show_param == "shared_with_me":
if not self.request.user.is_authenticated:
media = Media.objects.none()
else:
base_queryset = Media.objects.prefetch_related("user")
user_media_filters = {'permissions__user': request.user}
media = base_queryset.filter(**user_media_filters)
base_queryset = Media.objects.prefetch_related("user", "tags")
# Build OR conditions similar to _get_media_queryset
conditions = Q(permissions__user=request.user)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_filters = {'category__in': rbac_categories}
conditions |= Q(category__in=rbac_categories)
rbac_media = base_queryset.filter(**rbac_filters)
media = media.union(rbac_media)
media = media.order_by("-add_date")[:1000] # limit to 1000 results
media = base_queryset.filter(conditions).distinct()
elif author_param:
user_queryset = User.objects.all()
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").order_by("-add_date")
media = Media.objects.filter(user=user).prefetch_related("user", "tags")
else:
media = self._get_media_queryset(request, user)
already_sorted = True
else:
media = self._get_media_queryset(request)
if is_mediacms_editor(self.request.user):
media = Media.objects.prefetch_related("user", "tags")
else:
media = self._get_media_queryset(request)
already_sorted = True
if query:
query = helpers.clean_query(query)
q_parts = [q_part.rstrip("y") for q_part in query.split() if q_part not in STOP_WORDS]
if q_parts:
query = SearchQuery(q_parts[0] + ":*", search_type="raw")
for part in q_parts[1:]:
query &= SearchQuery(part + ":*", search_type="raw")
else:
query = None
if query:
media = media.filter(search=query)
if tag:
media = media.filter(tags__title=tag)
if media_type:
media = media.filter(media_type=media_type)
if upload_date and gte:
media = media.filter(add_date__gte=gte)
if duration:
if duration == '0-20':
media = media.filter(duration__gte=0, duration__lt=1200)
elif duration == '20-40':
media = media.filter(duration__gte=1200, duration__lt=2400)
elif duration == '40-60':
media = media.filter(duration__gte=2400, duration__lt=3600)
elif duration == '60-120':
media = media.filter(duration__gte=3600)
if publish_state and publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state)
if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}")
media = media[:1000]
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
tags_set = set()
for media_obj in page:
for tag in media_obj.tags.all():
tags_set.add(tag.title)
tags = ", ".join(sorted(tags_set))
response = paginator.get_paginated_response(serializer.data)
response.data['tags'] = tags
return response
@swagger_auto_schema(
manual_parameters=[
@@ -194,6 +298,16 @@ class MediaBulkUserActions(APIView):
"set_state",
"change_owner",
"copy_media",
"get_ownership",
"set_ownership",
"remove_ownership",
"playlist_membership",
"category_membership",
"tag_membership",
"add_to_category",
"remove_from_category",
"add_tags",
"remove_tags",
],
),
'playlist_ids': openapi.Schema(
@@ -201,8 +315,28 @@ class MediaBulkUserActions(APIView):
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)",
),
'category_uids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_STRING),
description="List of category UIDs (required for add_to_category and remove_from_category actions)",
),
'tag_titles': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_STRING),
description="List of tag titles (required for add_tags and remove_tags actions)",
),
'state': openapi.Schema(type=openapi.TYPE_STRING, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]),
'owner': openapi.Schema(type=openapi.TYPE_STRING, description="New owner username (required for change_owner action)"),
'ownership_type': openapi.Schema(
type=openapi.TYPE_STRING,
description="Ownership type to filter/set/remove (required for get_ownership, set_ownership, and remove_ownership actions)",
enum=["viewer", "editor", "owner"],
),
'users': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_STRING),
description="List of usernames (required for set_ownership and remove_ownership actions)",
),
},
),
tags=['Media'],
@@ -215,28 +349,23 @@ class MediaBulkUserActions(APIView):
},
)
def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', [])
action = request.data.get('action')
# Validate required parameters
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)
# Get media objects owned by the user
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments":
media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"})
@@ -307,12 +436,10 @@ class MediaBulkUserActions(APIView):
if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
# Check if user can set public state
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
# Update media state
for m in media:
m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
@@ -343,10 +470,175 @@ class MediaBulkUserActions(APIView):
elif action == "copy_media":
for m in media:
copy_media(m.id)
copy_media(m)
return Response({"detail": f"{media.count()} media items copied"})
elif action == "get_ownership":
ownership_type = request.data.get('ownership_type')
if not ownership_type:
return Response({"detail": "ownership_type is required for get_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
valid_ownership_types = ["viewer", "editor", "owner"]
if ownership_type not in valid_ownership_types:
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
media_count = media.count()
users = (
MediaPermission.objects.filter(media__in=media, permission=ownership_type)
.values('user__name', 'user__username')
.annotate(media_count=Count('media', distinct=True))
.filter(media_count=media_count)
)
results = [f"{user['user__name']} - {user['user__username']}" for user in users]
return Response({'results': results})
elif action == "set_ownership":
ownership_type = request.data.get('ownership_type')
if not ownership_type:
return Response({"detail": "ownership_type is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
valid_ownership_types = ["viewer", "editor", "owner"]
if ownership_type not in valid_ownership_types:
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
usernames = request.data.get('users', [])
if not usernames:
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
users = User.objects.filter(username__in=usernames)
if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
for m in media:
for user in users:
# Create or update MediaPermission
MediaPermission.objects.update_or_create(user=user, media=m, defaults={'owner_user': request.user, 'permission': ownership_type})
return Response({"detail": "Action succeeded"})
elif action == "remove_ownership":
ownership_type = request.data.get('ownership_type')
if not ownership_type:
return Response({"detail": "ownership_type is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
valid_ownership_types = ["viewer", "editor", "owner"]
if ownership_type not in valid_ownership_types:
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
usernames = request.data.get('users', [])
if not usernames:
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
users = User.objects.filter(username__in=usernames)
if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
return Response({"detail": "Action succeeded"})
elif action == "playlist_membership":
media_count = media.count()
results = list(
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
.values('id', 'friendly_token', 'title')
.annotate(media_count=Count('playlistmedia__media', distinct=True))
.filter(media_count=media_count)
)
return Response({'results': results})
elif action == "category_membership":
media_count = media.count()
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results})
elif action == "tag_membership":
media_count = media.count()
results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
return Response({'results': results})
elif action == "add_to_category":
category_uids = request.data.get('category_uids', [])
if not category_uids:
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.filter(uid__in=category_uids)
if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
added_count = 0
for category in categories:
for m in media:
if not m.category.filter(uid=category.uid).exists():
m.category.add(category)
added_count += 1
return Response({"detail": f"Added {added_count} media items to {categories.count()} categories"})
elif action == "remove_from_category":
category_uids = request.data.get('category_uids', [])
if not category_uids:
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.filter(uid__in=category_uids)
if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0
for category in categories:
for m in media:
if m.category.filter(uid=category.uid).exists():
m.category.remove(category)
removed_count += 1
return Response({"detail": f"Removed {removed_count} media items from {categories.count()} categories"})
elif action == "add_tags":
tag_titles = request.data.get('tag_titles', [])
if not tag_titles:
return Response({"detail": "tag_titles is required for add_tags action"}, status=status.HTTP_400_BAD_REQUEST)
tags = Tag.objects.filter(title__in=tag_titles)
if not tags:
return Response({"detail": "No matching tags found"}, status=status.HTTP_400_BAD_REQUEST)
added_count = 0
for tag in tags:
for m in media:
if not m.tags.filter(title=tag.title).exists():
m.tags.add(tag)
added_count += 1
return Response({"detail": f"Added {added_count} media items to {tags.count()} tags"})
elif action == "remove_tags":
tag_titles = request.data.get('tag_titles', [])
if not tag_titles:
return Response({"detail": "tag_titles is required for remove_tags action"}, status=status.HTTP_400_BAD_REQUEST)
tags = Tag.objects.filter(title__in=tag_titles)
if not tags:
return Response({"detail": "No matching tags found"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0
for tag in tags:
for m in media:
if m.tags.filter(title=tag.title).exists():
m.tags.remove(tag)
removed_count += 1
return Response({"detail": f"Removed {removed_count} media items from {tags.count()} tags"})
else:
return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST)
@@ -673,13 +965,26 @@ class MediaSearch(APIView):
author = params.get("author", "").strip()
upload_date = params.get('upload_date', '').strip()
sort_by_options = ["title", "add_date", "edit_date", "views", "likes"]
if sort_by not in sort_by_options:
sort_by = "add_date"
if ordering == "asc":
ordering = ""
else:
ordering = "-"
# Handle combined sort options (e.g., title_asc, views_desc)
parsed_combined = False
if sort_by and '_' in sort_by:
parts = sort_by.rsplit('_', 1)
if len(parts) == 2 and parts[1] in ['asc', 'desc']:
field, direction = parts
if field in ["title", "add_date", "edit_date", "views", "likes"]:
sort_by = field
ordering = "" if direction == "asc" else "-"
parsed_combined = True
# Fall back to legacy handling only if we didn't parse a combined option
if not parsed_combined:
sort_by_options = ["title", "add_date", "edit_date", "views", "likes"]
if sort_by not in sort_by_options:
sort_by = "add_date"
if ordering == "asc":
ordering = ""
else:
ordering = "-"
if media_type not in ["video", "image", "audio", "pdf"]:
media_type = None
@@ -689,11 +994,15 @@ class MediaSearch(APIView):
return Response(ret, status=status.HTTP_200_OK)
if request.user.is_authenticated:
basic_query = Q(listable=True) | Q(permissions__user=request.user)
if is_mediacms_editor(self.request.user):
media = Media.objects.prefetch_related("user", "tags")
basic_query = Q()
else:
basic_query = Q(listable=True) | Q(permissions__user=request.user) | Q(user=request.user)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
basic_query |= Q(category__in=rbac_categories)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
basic_query |= Q(category__in=rbac_categories)
else:
basic_query = Q(listable=True)

View File

@@ -94,7 +94,7 @@ def add_subtitle(request):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
# Initialize variables
@@ -146,7 +146,7 @@ def edit_subtitle(request):
if not subtitle:
return HttpResponseRedirect("/")
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(subtitle.media)):
return HttpResponseRedirect("/")
context = {"subtitle": subtitle, "action": action}
@@ -252,7 +252,7 @@ def video_chapters(request, friendly_token):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
try:
@@ -343,6 +343,10 @@ def publish_media(request):
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not (request.user.has_owner_access_to_media(media) or is_mediacms_editor(request.user)):
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, f"Permission to publish is not grated by the owner: {media.user.name}"))
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
@@ -370,7 +374,7 @@ def edit_chapters(request):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
chapters = media.chapter_data
@@ -395,7 +399,7 @@ def trim_video(request, friendly_token):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists()
@@ -426,11 +430,11 @@ def edit_video(request):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
if not (is_mediacms_editor(request.user) or request.user.has_contributor_access_to_media(media)):
return HttpResponseRedirect("/")
if media.media_type not in ["video", "audio"]:
messages.add_message(request, messages.INFO, "Media is not video")
messages.add_message(request, messages.INFO, "Media is not video or audio")
return HttpResponseRedirect(media.get_absolute_url())
if not settings.ALLOW_VIDEO_TRIMMER:
@@ -629,10 +633,12 @@ def view_media(request):
if request.user.is_authenticated:
if request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user):
context["CAN_DELETE_MEDIA"] = True
context["CAN_EDIT_MEDIA"] = True
context["CAN_DELETE_COMMENTS"] = True
if request.user == media.user or is_mediacms_editor(request.user):
context["CAN_DELETE_MEDIA"] = True
# in case media is video and is processing (eg the case a video was just uploaded)
# attempt to show it (rather than showing a blank video player)
if media.media_type == 'video':

View File

@@ -1,4 +1,5 @@
from django.conf import settings
from django.db.models import Q
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
@@ -94,7 +95,11 @@ class PlaylistDetail(APIView):
serializer = PlaylistDetailSerializer(playlist, context={"request": request})
playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user")
# If user is the author, show all media; otherwise, show only public and unlisted media
if request.user.is_authenticated and request.user == playlist.user:
playlist_media = PlaylistMedia.objects.filter(playlist=playlist).prefetch_related("media__user").select_related("media")
else:
playlist_media = PlaylistMedia.objects.filter(playlist=playlist).filter(Q(media__state="public") | Q(media__state="unlisted")).prefetch_related("media__user").select_related("media")
playlist_media = [c.media for c in playlist_media]