mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-25 12:52:30 -05:00
replace media, shared state, better category options
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ media_files/hls/
|
||||
media_files/chunks/
|
||||
media_files/uploads/
|
||||
media_files/tinymce_media/
|
||||
media_files/userlogos/
|
||||
postgres_data/
|
||||
celerybeat-schedule
|
||||
logs/
|
||||
|
||||
@@ -563,7 +563,8 @@ ALLOW_VIDEO_TRIMMER = True
|
||||
|
||||
ALLOW_CUSTOM_MEDIA_URLS = False
|
||||
|
||||
# Whether to allow anonymous users to list all users
|
||||
ALLOW_MEDIA_REPLACEMENT = False
|
||||
|
||||
ALLOW_ANONYMOUS_USER_LISTING = True
|
||||
|
||||
# Who can see the members page
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.3"
|
||||
VERSION = "7.4"
|
||||
|
||||
@@ -58,6 +58,7 @@ def stuff(request):
|
||||
ret["USE_RBAC"] = settings.USE_RBAC
|
||||
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
||||
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
|
||||
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
|
||||
ret["VERSION"] = VERSION
|
||||
|
||||
if request.user.is_superuser:
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
|
||||
from .methods import get_next_state, is_mediacms_editor
|
||||
from .models import MEDIA_STATES, Category, Media, Subtitle
|
||||
from .widgets import CategoryModalWidget
|
||||
|
||||
|
||||
class CustomField(Field):
|
||||
@@ -121,13 +122,18 @@ class MediaPublishForm(forms.ModelForm):
|
||||
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
|
||||
|
||||
widgets = {
|
||||
"category": MultipleSelect(),
|
||||
"category": CategoryModalWidget(),
|
||||
}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
self.user = user
|
||||
super(MediaPublishForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
|
||||
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
|
||||
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
|
||||
self.actual_state = self.instance.state if self.instance.pk else None
|
||||
|
||||
if not is_mediacms_editor(user):
|
||||
for field in ["featured", "reported_times", "is_reviewed"]:
|
||||
self.fields[field].disabled = True
|
||||
@@ -140,6 +146,13 @@ class MediaPublishForm(forms.ModelForm):
|
||||
valid_states.append(self.instance.state)
|
||||
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
|
||||
|
||||
if self.is_shared:
|
||||
current_choices = list(self.fields["state"].choices)
|
||||
current_choices.insert(0, ("shared", "Shared"))
|
||||
self.fields["state"].choices = current_choices
|
||||
self.fields["state"].initial = "shared"
|
||||
self.initial["state"] = "shared"
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
if is_mediacms_editor(user):
|
||||
pass
|
||||
@@ -178,7 +191,35 @@ class MediaPublishForm(forms.ModelForm):
|
||||
state = cleaned_data.get("state")
|
||||
categories = cleaned_data.get("category")
|
||||
|
||||
if state in ['private', 'unlisted']:
|
||||
if self.is_shared and state != "shared":
|
||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||
state_index = None
|
||||
for i, layout_item in enumerate(self.helper.layout):
|
||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||
state_index = i
|
||||
break
|
||||
|
||||
if state_index is not None:
|
||||
layout_items = list(self.helper.layout)
|
||||
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
|
||||
if not cleaned_data.get('confirm_state'):
|
||||
if state == 'private':
|
||||
error_parts = []
|
||||
if self.has_rbac_categories:
|
||||
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
|
||||
if self.has_custom_permissions:
|
||||
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
|
||||
|
||||
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
|
||||
self.add_error('confirm_state', error_message)
|
||||
else:
|
||||
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
elif state in ['private', 'unlisted']:
|
||||
custom_permissions = self.instance.permissions.exists()
|
||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
if rbac_categories or custom_permissions:
|
||||
@@ -189,7 +230,7 @@ class MediaPublishForm(forms.ModelForm):
|
||||
state_index = i
|
||||
break
|
||||
|
||||
if state_index:
|
||||
if state_index is not None:
|
||||
layout_items = list(self.helper.layout)
|
||||
layout_items.insert(state_index + 1, CustomField('confirm_state'))
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
@@ -202,11 +243,24 @@ class MediaPublishForm(forms.ModelForm):
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
|
||||
if state == "shared":
|
||||
cleaned_data["state"] = self.actual_state
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
data = self.cleaned_data
|
||||
state = data.get("state")
|
||||
|
||||
# If transitioning from shared to private, remove all sharing
|
||||
if self.is_shared and state == 'private' and data.get('confirm_state'):
|
||||
# Remove all custom permissions
|
||||
self.instance.permissions.all().delete()
|
||||
# Remove RBAC categories
|
||||
rbac_cats = self.instance.category.filter(is_rbac_category=True)
|
||||
self.instance.category.remove(*rbac_cats)
|
||||
|
||||
if state != self.initial["state"]:
|
||||
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
|
||||
|
||||
@@ -333,3 +387,35 @@ class ContactForm(forms.Form):
|
||||
if user.is_authenticated:
|
||||
self.fields.pop("name")
|
||||
self.fields.pop("from_email")
|
||||
|
||||
|
||||
class ReplaceMediaForm(forms.Form):
|
||||
new_media_file = forms.FileField(
|
||||
required=True,
|
||||
label="New Media File",
|
||||
help_text="Select a new file to replace the current media",
|
||||
)
|
||||
|
||||
def __init__(self, media_instance, *args, **kwargs):
|
||||
self.media_instance = media_instance
|
||||
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = True
|
||||
self.helper.form_class = 'post-form'
|
||||
self.helper.form_method = 'post'
|
||||
self.helper.form_enctype = "multipart/form-data"
|
||||
self.helper.form_show_errors = False
|
||||
self.helper.layout = Layout(
|
||||
CustomField('new_media_file'),
|
||||
)
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
|
||||
|
||||
def clean_new_media_file(self):
|
||||
file = self.cleaned_data.get("new_media_file", False)
|
||||
if file:
|
||||
if file.size > settings.UPLOAD_MAX_SIZE:
|
||||
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
|
||||
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
|
||||
return file
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "إزالة من القائمة",
|
||||
"Remove tag": "إزالة العلامة",
|
||||
"Remove user": "إزالة المستخدم",
|
||||
"Replace": "",
|
||||
"SAVE": "حفظ",
|
||||
"SEARCH": "بحث",
|
||||
"SHARE": "مشاركة",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "",
|
||||
"Remove tag": "",
|
||||
"Remove user": "",
|
||||
"Replace": "",
|
||||
"SAVE": "সংরক্ষণ করুন",
|
||||
"SEARCH": "অনুসন্ধান",
|
||||
"SHARE": "শেয়ার করুন",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Fjern fra liste",
|
||||
"Remove tag": "Fjern tag",
|
||||
"Remove user": "Fjern bruger",
|
||||
"Replace": "",
|
||||
"SAVE": "GEM",
|
||||
"SEARCH": "SØG",
|
||||
"SHARE": "DEL",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Aus Liste entfernen",
|
||||
"Remove tag": "Tag entfernen",
|
||||
"Remove user": "Benutzer entfernen",
|
||||
"Replace": "",
|
||||
"SAVE": "SPEICHERN",
|
||||
"SEARCH": "SUCHE",
|
||||
"SHARE": "TEILEN",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Αφαίρεση από λίστα",
|
||||
"Remove tag": "Αφαίρεση ετικέτας",
|
||||
"Remove user": "Αφαίρεση χρήστη",
|
||||
"Replace": "",
|
||||
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
|
||||
"SEARCH": "ΑΝΑΖΗΤΗΣΗ",
|
||||
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",
|
||||
|
||||
@@ -165,6 +165,7 @@ translation_strings = {
|
||||
"Recommended": "",
|
||||
"Record Screen": "",
|
||||
"Register": "",
|
||||
"Replace": "",
|
||||
"Remove category": "",
|
||||
"Remove from list": "",
|
||||
"Remove tag": "",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Eliminar de la lista",
|
||||
"Remove tag": "Eliminar etiqueta",
|
||||
"Remove user": "Eliminar usuario",
|
||||
"Replace": "",
|
||||
"SAVE": "GUARDAR",
|
||||
"SEARCH": "BUSCAR",
|
||||
"SHARE": "COMPARTIR",
|
||||
|
||||
@@ -163,6 +163,7 @@ translation_strings = {
|
||||
"Remove from list": "Supprimer de la liste",
|
||||
"Remove tag": "Supprimer le tag",
|
||||
"Remove user": "Supprimer l'utilisateur",
|
||||
"Replace": "",
|
||||
"SAVE": "ENREGISTRER",
|
||||
"SEARCH": "RECHERCHER",
|
||||
"SHARE": "PARTAGER",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "",
|
||||
"Remove tag": "",
|
||||
"Remove user": "",
|
||||
"Replace": "",
|
||||
"SAVE": "שמור",
|
||||
"SEARCH": "חפש",
|
||||
"SHARE": "שתף",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "सूची से हटाएं",
|
||||
"Remove tag": "टैग हटाएं",
|
||||
"Remove user": "उपयोगकर्ता हटाएं",
|
||||
"Replace": "",
|
||||
"SAVE": "सहेजें",
|
||||
"SEARCH": "खोजें",
|
||||
"SHARE": "साझा करें",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Hapus dari daftar",
|
||||
"Remove tag": "Hapus tag",
|
||||
"Remove user": "Hapus pengguna",
|
||||
"Replace": "",
|
||||
"SAVE": "SIMPAN",
|
||||
"SEARCH": "CARI",
|
||||
"SHARE": "BAGIKAN",
|
||||
|
||||
@@ -163,6 +163,7 @@ translation_strings = {
|
||||
"Remove from list": "Rimuovi dalla lista",
|
||||
"Remove tag": "Rimuovi tag",
|
||||
"Remove user": "Rimuovi utente",
|
||||
"Replace": "",
|
||||
"SAVE": "SALVA",
|
||||
"SEARCH": "CERCA",
|
||||
"SHARE": "CONDIVIDI",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "リストから削除",
|
||||
"Remove tag": "タグを削除",
|
||||
"Remove user": "ユーザーを削除",
|
||||
"Replace": "",
|
||||
"SAVE": "保存",
|
||||
"SEARCH": "検索",
|
||||
"SHARE": "共有",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "목록에서 제거",
|
||||
"Remove tag": "태그 제거",
|
||||
"Remove user": "사용자 제거",
|
||||
"Replace": "",
|
||||
"SAVE": "저장",
|
||||
"SEARCH": "검색",
|
||||
"SHARE": "공유",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Verwijderen uit lijst",
|
||||
"Remove tag": "Tag verwijderen",
|
||||
"Remove user": "Gebruiker verwijderen",
|
||||
"Replace": "",
|
||||
"SAVE": "OPSLAAN",
|
||||
"SEARCH": "ZOEKEN",
|
||||
"SHARE": "DELEN",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Remover da lista",
|
||||
"Remove tag": "Remover tag",
|
||||
"Remove user": "Remover usuário",
|
||||
"Replace": "",
|
||||
"SAVE": "SALVAR",
|
||||
"SEARCH": "PESQUISAR",
|
||||
"SHARE": "COMPARTILHAR",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Удалить из списка",
|
||||
"Remove tag": "Удалить тег",
|
||||
"Remove user": "Удалить пользователя",
|
||||
"Replace": "",
|
||||
"SAVE": "СОХРАНИТЬ",
|
||||
"SEARCH": "ПОИСК",
|
||||
"SHARE": "ПОДЕЛИТЬСЯ",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Odstrani s seznama",
|
||||
"Remove tag": "Odstrani oznako",
|
||||
"Remove user": "Odstrani uporabnika",
|
||||
"Replace": "",
|
||||
"SAVE": "SHRANI",
|
||||
"SEARCH": "ISKANJE",
|
||||
"SHARE": "DELI",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "Listeden kaldır",
|
||||
"Remove tag": "Etiketi kaldır",
|
||||
"Remove user": "Kullanıcıyı kaldır",
|
||||
"Replace": "",
|
||||
"SAVE": "KAYDET",
|
||||
"SEARCH": "ARA",
|
||||
"SHARE": "PAYLAŞ",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "فہرست سے ہٹائیں",
|
||||
"Remove tag": "ٹیگ ہٹائیں",
|
||||
"Remove user": "صارف ہٹائیں",
|
||||
"Replace": "",
|
||||
"SAVE": "محفوظ کریں",
|
||||
"SEARCH": "تلاش کریں",
|
||||
"SHARE": "شیئر کریں",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "",
|
||||
"Remove tag": "",
|
||||
"Remove user": "",
|
||||
"Replace": "",
|
||||
"SAVE": "保存",
|
||||
"SEARCH": "搜索",
|
||||
"SHARE": "分享",
|
||||
|
||||
@@ -162,6 +162,7 @@ translation_strings = {
|
||||
"Remove from list": "",
|
||||
"Remove tag": "",
|
||||
"Remove user": "",
|
||||
"Replace": "",
|
||||
"SAVE": "儲存",
|
||||
"SEARCH": "搜尋",
|
||||
"SHARE": "分享",
|
||||
|
||||
@@ -270,7 +270,9 @@ class Media(models.Model):
|
||||
if self.media_file != self.__original_media_file:
|
||||
# set this otherwise gets to infinite loop
|
||||
self.__original_media_file = self.media_file
|
||||
self.media_init()
|
||||
from .. import tasks
|
||||
|
||||
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
|
||||
|
||||
# for video files, if user specified a different time
|
||||
# to automatically grub thumbnail
|
||||
@@ -417,6 +419,11 @@ class Media(models.Model):
|
||||
self.media_type = "image"
|
||||
elif kind == "pdf":
|
||||
self.media_type = "pdf"
|
||||
elif kind == "audio":
|
||||
self.media_type = "audio"
|
||||
elif kind == "video":
|
||||
self.media_type = "video"
|
||||
|
||||
if self.media_type in ["image", "pdf"]:
|
||||
self.encoding_status = "success"
|
||||
else:
|
||||
|
||||
@@ -101,10 +101,17 @@ class MediaSerializer(serializers.ModelSerializer):
|
||||
class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
user = serializers.ReadOnlyField(source="user.username")
|
||||
url = serializers.SerializerMethodField()
|
||||
is_shared = serializers.SerializerMethodField()
|
||||
|
||||
def get_url(self, obj):
|
||||
return self.context["request"].build_absolute_uri(obj.get_absolute_url())
|
||||
|
||||
def get_is_shared(self, obj):
|
||||
"""Check if media has custom permissions or RBAC categories"""
|
||||
custom_permissions = obj.permissions.exists()
|
||||
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
|
||||
return custom_permissions or rbac_categories
|
||||
|
||||
class Meta:
|
||||
model = Media
|
||||
read_only_fields = (
|
||||
@@ -133,6 +140,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
"edit_date",
|
||||
"media_type",
|
||||
"state",
|
||||
"is_shared",
|
||||
"duration",
|
||||
"thumbnail_url",
|
||||
"poster_url",
|
||||
|
||||
@@ -625,6 +625,18 @@ def create_hls(friendly_token):
|
||||
return True
|
||||
|
||||
|
||||
@task(name="media_init", queue="short_tasks")
|
||||
def media_init(friendly_token):
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=friendly_token)
|
||||
except: # noqa
|
||||
logger.info("failed to get media with friendly_token %s" % friendly_token)
|
||||
return False
|
||||
media.media_init()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@task(name="check_running_states", queue="short_tasks")
|
||||
def check_running_states():
|
||||
# Experimental - unused
|
||||
|
||||
@@ -20,6 +20,7 @@ urlpatterns = [
|
||||
re_path(r"^contact$", views.contact, name="contact"),
|
||||
re_path(r"^publish", views.publish_media, name="publish_media"),
|
||||
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
|
||||
re_path(r"^replace_media", views.replace_media, name="replace_media"),
|
||||
re_path(r"^edit_video", views.edit_video, name="edit_video"),
|
||||
re_path(r"^edit", views.edit_media, name="edit_media"),
|
||||
re_path(r"^embed", views.embed_media, name="get_embed"),
|
||||
|
||||
@@ -32,6 +32,7 @@ from .pages import members # noqa: F401
|
||||
from .pages import publish_media # noqa: F401
|
||||
from .pages import recommended_media # noqa: F401
|
||||
from .pages import record_screen # noqa: F401
|
||||
from .pages import replace_media # noqa: F401
|
||||
from .pages import search # noqa: F401
|
||||
from .pages import setlanguage # noqa: F401
|
||||
from .pages import sitemap # noqa: F401
|
||||
|
||||
@@ -226,8 +226,13 @@ class MediaList(APIView):
|
||||
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 publish_state:
|
||||
if publish_state == 'shared':
|
||||
# Filter media that have custom permissions OR RBAC categories
|
||||
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
|
||||
media = media.filter(shared_conditions).distinct()
|
||||
elif publish_state in ['private', 'public', 'unlisted']:
|
||||
media = media.filter(state=publish_state)
|
||||
|
||||
if not already_sorted:
|
||||
media = media.order_by(f"{ordering}{sort_by}")
|
||||
@@ -799,13 +804,14 @@ class MediaDetail(APIView):
|
||||
|
||||
serializer = MediaSerializer(media, data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user)
|
||||
# no need to update the media file itself, only the metadata
|
||||
# if request.data.get('media_file'):
|
||||
# media_file = request.data["media_file"]
|
||||
# serializer.save(user=request.user, media_file=media_file)
|
||||
# media_file = request.data["media_file"]
|
||||
# media.state = helpers.get_default_state(request.user)
|
||||
# media.listable = False
|
||||
# serializer.save(user=request.user, media_file=media_file)
|
||||
# else:
|
||||
# serializer.save(user=request.user)
|
||||
# serializer.save(user=request.user)
|
||||
serializer.save(user=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -18,6 +19,7 @@ from ..forms import (
|
||||
EditSubtitleForm,
|
||||
MediaMetadataForm,
|
||||
MediaPublishForm,
|
||||
ReplaceMediaForm,
|
||||
SubtitleForm,
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
@@ -363,6 +365,76 @@ def publish_media(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def replace_media(request):
|
||||
"""Replace media file"""
|
||||
|
||||
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
friendly_token = request.GET.get("m", "").strip()
|
||||
if not friendly_token:
|
||||
return HttpResponseRedirect("/")
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not is_media_allowed_type(media):
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
if request.method == "POST":
|
||||
form = ReplaceMediaForm(media, request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
new_media_file = form.cleaned_data.get("new_media_file")
|
||||
|
||||
media.encodings.all().delete()
|
||||
|
||||
if media.thumbnail:
|
||||
helpers.rm_file(media.thumbnail.path)
|
||||
media.thumbnail = None
|
||||
if media.poster:
|
||||
helpers.rm_file(media.poster.path)
|
||||
media.poster = None
|
||||
if media.uploaded_thumbnail:
|
||||
helpers.rm_file(media.uploaded_thumbnail.path)
|
||||
media.uploaded_thumbnail = None
|
||||
if media.uploaded_poster:
|
||||
helpers.rm_file(media.uploaded_poster.path)
|
||||
media.uploaded_poster = None
|
||||
if media.sprites:
|
||||
helpers.rm_file(media.sprites.path)
|
||||
media.sprites = None
|
||||
if media.preview_file_path:
|
||||
helpers.rm_file(media.preview_file_path)
|
||||
media.preview_file_path = ""
|
||||
|
||||
if media.hls_file:
|
||||
hls_dir = os.path.dirname(media.hls_file)
|
||||
helpers.rm_dir(hls_dir)
|
||||
media.hls_file = ""
|
||||
|
||||
media.media_file = new_media_file
|
||||
|
||||
media.listable = False
|
||||
media.state = helpers.get_default_state(request.user)
|
||||
media.save()
|
||||
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
else:
|
||||
form = ReplaceMediaForm(media)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"cms/replace_media.html",
|
||||
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_chapters(request):
|
||||
"""Edit chapters"""
|
||||
|
||||
39
files/widgets.py
Normal file
39
files/widgets.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
class CategoryModalWidget(forms.SelectMultiple):
|
||||
"""Two-panel category selector with modal"""
|
||||
|
||||
class Media:
|
||||
css = {'all': ('css/category_modal.css',)}
|
||||
js = ('js/category_modal.js',)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
# Get all categories as JSON
|
||||
categories = []
|
||||
for opt_value, opt_label in self.choices:
|
||||
if opt_value: # Skip empty choice
|
||||
categories.append({'id': str(opt_value), 'title': str(opt_label)})
|
||||
|
||||
all_categories_json = json.dumps(categories)
|
||||
selected_ids_json = json.dumps([str(v) for v in (value or [])])
|
||||
|
||||
html = f'''<div class="category-widget" data-name="{name}">
|
||||
<div class="category-content">
|
||||
<div class="category-panel">
|
||||
<input type="text" class="category-search" placeholder="Search categories...">
|
||||
<div class="category-list scrollable" data-panel="left"></div>
|
||||
</div>
|
||||
<div class="category-panel">
|
||||
<h3>Selected Categories</h3>
|
||||
<div class="category-list scrollable" data-panel="right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-inputs"></div>
|
||||
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json}}}</script>
|
||||
</div>'''
|
||||
|
||||
return mark_safe(html)
|
||||
@@ -53,9 +53,9 @@ export function PlaylistItem(props) {
|
||||
<UnderThumbWrapper title={props.title} link={props.link}>
|
||||
{titleComponent()}
|
||||
{metaComponents()}
|
||||
<a href={props.link} title="" className="view-full-playlist">
|
||||
<span className="view-full-playlist">
|
||||
VIEW FULL PLAYLIST
|
||||
</a>
|
||||
</span>
|
||||
</UnderThumbWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,9 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
render() {
|
||||
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
|
||||
|
||||
const mediaState = MediaPageStore.get('media-data').state;
|
||||
const mediaData = MediaPageStore.get('media-data');
|
||||
const mediaState = mediaData.state;
|
||||
const isShared = mediaData.is_shared;
|
||||
|
||||
let stateTooltip = '';
|
||||
|
||||
@@ -121,6 +123,8 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
break;
|
||||
}
|
||||
|
||||
const sharedTooltip = 'This media is shared with specific users or categories';
|
||||
|
||||
return (
|
||||
<div className="media-title-banner">
|
||||
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
@@ -129,15 +133,28 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
|
||||
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
|
||||
|
||||
{'public' !== mediaState ? (
|
||||
{isShared || 'public' !== mediaState ? (
|
||||
<div className="media-labels-area">
|
||||
<div className="media-labels-area-inner">
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
{isShared ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>shared</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={sharedTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : 'public' !== mediaState ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -10,7 +10,9 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
render() {
|
||||
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
|
||||
|
||||
const mediaState = MediaPageStore.get('media-data').state;
|
||||
const mediaData = MediaPageStore.get('media-data');
|
||||
const mediaState = mediaData.state;
|
||||
const isShared = mediaData.is_shared;
|
||||
|
||||
let stateTooltip = '';
|
||||
|
||||
@@ -23,6 +25,8 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
break;
|
||||
}
|
||||
|
||||
const sharedTooltip = 'This media is shared with specific users or categories';
|
||||
|
||||
return (
|
||||
<div className="media-title-banner">
|
||||
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
@@ -31,15 +35,28 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
|
||||
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
|
||||
|
||||
{'public' !== mediaState ? (
|
||||
{isShared || 'public' !== mediaState ? (
|
||||
<div className="media-labels-area">
|
||||
<div className="media-labels-area-inner">
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
{isShared ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>shared</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={sharedTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : 'public' !== mediaState ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -32,6 +32,7 @@ const filters = {
|
||||
{ id: 'private', title: translateString('Private') },
|
||||
{ id: 'unlisted', title: translateString('Unlisted') },
|
||||
{ id: 'public', title: translateString('Published') },
|
||||
{ id: 'shared', title: translateString('Shared') },
|
||||
],
|
||||
sort_by: [
|
||||
{ id: 'date_added_desc', title: translateString('Upload date (newest)') },
|
||||
|
||||
1
static/css/category_modal.css
Normal file
1
static/css/category_modal.css
Normal file
@@ -0,0 +1 @@
|
||||
.category-widget{margin:10px 0}.category-content{display:flex;gap:20px}.category-panel{flex:1;display:flex;flex-direction:column;min-width:0}.category-panel h3{margin:0 0 12px;font-size:16px;font-weight:normal;color:#333}.category-search{width:100%;padding:8px 12px;margin-bottom:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.category-list{border:1px solid #ddd;border-radius:4px;padding:8px;background:#f9f9f9;min-height:120px;max-height:200px}.category-list.scrollable{overflow-y:auto}.category-list::-webkit-scrollbar{width:8px}.category-list::-webkit-scrollbar-track{background:#f1f1f1;border-radius:4px}.category-list::-webkit-scrollbar-thumb{background:#ccc;border-radius:4px}.category-list::-webkit-scrollbar-thumb:hover{background:#aaa}.category-item{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;margin-bottom:4px;background:#fff;border:1px solid #e0e0e0;border-radius:4px;font-size:14px;cursor:pointer;transition:all .2s}.category-item:hover{background:#f0f7ff;border-color:#007bff}.category-item span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.category-item button{background:none;border:none;font-size:18px;font-weight:bold;cursor:pointer;padding:0;width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all .2s}.category-item .add-btn{color:#28a745}.category-item .add-btn:hover{background:rgba(40,167,69,.1)}.category-item .remove-btn{color:#dc3545}.category-item .remove-btn:hover{background:rgba(220,53,69,.1)}.empty-message{padding:30px 15px;text-align:center;color:#999;font-size:13px;font-style:italic}@media (max-width:768px){.category-content{flex-direction:column;gap:16px}.category-list{max-height:150px}}
|
||||
File diff suppressed because one or more lines are too long
1
static/js/category_modal.js
Normal file
1
static/js/category_modal.js
Normal file
@@ -0,0 +1 @@
|
||||
document.addEventListener('DOMContentLoaded',()=>{document.querySelectorAll('.category-widget').forEach(w=>{const d=JSON.parse(w.querySelector('.category-data').textContent),all=d.all,sel=new Set(d.selected),name=w.dataset.name,srch=w.querySelector('.category-search'),left=w.querySelector('[data-panel="left"]'),right=w.querySelector('[data-panel="right"]'),inputs=w.querySelector('.hidden-inputs');const upd=()=>{left.innerHTML=all.filter(c=>!sel.has(c.id)&&(!srch.value||c.title.toLowerCase().includes(srch.value.toLowerCase()))).map(c=>`<div class="category-item" data-id="${c.id}"><span>${c.title}</span><button class="add-btn" type="button">+</button></div>`).join('')||'<div class="empty-message">No categories available</div>';right.innerHTML=[...sel].map(id=>all.find(c=>c.id==id)).filter(Boolean).map(c=>`<div class="category-item" data-id="${c.id}"><span>${c.title}</span><button class="remove-btn" type="button">×</button></div>`).join('')||'<div class="empty-message">No categories selected</div>';inputs.innerHTML=[...sel].map(id=>`<input type="hidden" name="${name}" value="${id}">`).join('')};srch.oninput=upd;left.onclick=e=>{const item=e.target.closest('.category-item');if(item){sel.add(item.dataset.id);upd()}};right.onclick=e=>{const item=e.target.closest('.category-item');if(item){sel.delete(item.dataset.id);upd()}};upd()})});
|
||||
File diff suppressed because one or more lines are too long
@@ -4,12 +4,16 @@
|
||||
<style>
|
||||
/* Form group styling */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.25rem 1.25rem 0.75rem 1.25rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Control label container styling */
|
||||
.control-label-container {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Label styling */
|
||||
@@ -56,6 +60,24 @@
|
||||
border-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* Better input field styling */
|
||||
.controls input:not([type="checkbox"]):not([type="radio"]),
|
||||
.controls select,
|
||||
.controls textarea {
|
||||
background: white;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.controls input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.controls select:focus,
|
||||
.controls textarea:focus {
|
||||
outline: none;
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="form-group{% if field.errors %} has-error{% endif %}">
|
||||
|
||||
@@ -43,6 +43,14 @@
|
||||
</li>
|
||||
{% endcomment %}
|
||||
{% endif %}
|
||||
{% if ALLOW_MEDIA_REPLACEMENT %}
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'replace_media' %}?m={{media_object.friendly_token}}"
|
||||
style="text-decoration: none; {% if active_tab == 'replace' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
{{ "Replace" | custom_translate:LANGUAGE_CODE}}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li style="display: inline-block;">
|
||||
<a href="{% url 'publish_media' %}?m={{media_object.friendly_token}}"
|
||||
style="text-decoration: none; {% if active_tab == 'publish' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
|
||||
|
||||
24
templates/cms/replace_media.html
Normal file
24
templates/cms/replace_media.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block headtitle %}Replace media - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block headermeta %}{% endblock headermeta %}
|
||||
|
||||
{% block innercontent %}
|
||||
<div class="user-action-form-wrap">
|
||||
{% include "cms/media_nav.html" with active_tab="replace" %}
|
||||
<div style="max-width: 900px; margin: 0 auto; padding: 20px; border-radius: 8px; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1);">
|
||||
|
||||
<div style="margin-bottom: 20px; padding: 15px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0; color: #856404;">⚠️ Important Information</h4>
|
||||
<p style="margin-bottom: 0; color: #856404;">The new file will be processed but all metadata is preserved</p>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock innercontent %}
|
||||
Reference in New Issue
Block a user