diff --git a/Makefile b/Makefile index 3d40cb14..87936e91 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: admin-shell build-frontend admin-shell: - @container_id=$$(docker-compose ps -q web); \ + @container_id=$$(docker compose ps -q web); \ if [ -z "$$container_id" ]; then \ echo "Web container not found"; \ exit 1; \ diff --git a/README.md b/README.md index 1636f8b6..34695c7f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin * [Configuration](docs/admins_docs.md#5-configuration) page * [Transcoding](docs/transcoding.md) page * [Developer Experience](docs/dev_exp.md) page +* [Media Permissions](docs/media_permissions.md) page ## Technology diff --git a/cms/settings.py b/cms/settings.py index 075c2518..c6205cd5 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -498,6 +498,9 @@ ALLOW_VIDEO_TRIMMER = True ALLOW_CUSTOM_MEDIA_URLS = False +# Whether to allow anonymous users to list all users +ALLOW_ANONYMOUS_USER_LISTING = True + # ffmpeg options FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264 diff --git a/cms/version.py b/cms/version.py index 02f4b901..1d01958d 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "6.3.0" +VERSION = "6.4.0" diff --git a/docs/admins_docs.md b/docs/admins_docs.md index 071533e8..b9de5704 100644 --- a/docs/admins_docs.md +++ b/docs/admins_docs.md @@ -168,8 +168,6 @@ By default, all these services are enabled, but in order to create a scaleable d Also see the `Dockerfile` for other environment variables which you may wish to override. Application settings, eg. `FRONTEND_HOST` can also be overridden by updating the `deploy/docker/local_settings.py` file. -See example deployments in the sections below. These example deployments have been tested on `docker-compose version 1.27.4` running on `Docker version 19.03.13` - To run, update the configs above if necessary, build the image by running `docker compose build`, then run `docker compose run` ### Simple Deployment, accessed as http://localhost @@ -502,6 +500,16 @@ By default `CAN_COMMENT = "all"` means that all registered users can add comment - **advancedUser**, only users that are marked as advanced users can add comment. Admins or MediaCMS managers can make users advanced users by editing their profile and selecting advancedUser. +### 5.26 Control whether anonymous users can list all users + +By default, anonymous users can view the list of all users on the platform. To restrict this to authenticated users only, set: + +``` +ALLOW_ANONYMOUS_USER_LISTING = False +``` + +When set to False, only logged-in users will be able to access the user listing API endpoint. + ## 6. Manage pages to be written @@ -967,4 +975,4 @@ Visiting the admin, you will see the Identity Providers tab and you can add one. ## 25. Custom urls To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py -This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this. +This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this. diff --git a/docs/dev_exp.md b/docs/dev_exp.md index b66d87e9..9caa4d02 100644 --- a/docs/dev_exp.md +++ b/docs/dev_exp.md @@ -4,10 +4,10 @@ There is ongoing effort to provide a better developer experience and document it ## How to develop locally with Docker First install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/). -Then run `docker-compose -f docker-compose-dev.yaml up` +Then run `docker compose -f docker-compose-dev.yaml up` ``` -user@user:~/mediacms$ docker-compose -f docker-compose-dev.yaml up +user@user:~/mediacms$ docker compose -f docker-compose-dev.yaml up ``` In a few minutes the app will be available at http://localhost . Login via admin/admin @@ -37,7 +37,7 @@ Django starts at http://localhost and is reloading automatically. Making any cha If Django breaks due to an error (eg SyntaxError, while editing the code), you might have to restart it ``` -docker-compose -f docker-compose-dev.yaml restart web +docker compose -f docker-compose-dev.yaml restart web ``` @@ -62,9 +62,9 @@ In order to make changes to React code, edit code on frontend/src and check it's ### Development workflow with the frontend 1. Edit frontend/src/ files 2. Check changes on http://localhost:8088/ -3. Build frontend with `docker-compose -f docker-compose-dev.yaml exec frontend npm run dist` +3. Build frontend with `docker compose -f docker-compose-dev.yaml exec frontend npm run dist` 4. Copy static files to Django static folder with`cp -r frontend/dist/static/* static/` -5. Restart Django - `docker-compose -f docker-compose-dev.yaml restart web` so that it uses the new static files +5. Restart Django - `docker compose -f docker-compose-dev.yaml restart web` so that it uses the new static files 6. Commit the changes ### Helper commands @@ -81,7 +81,7 @@ Build the frontend: ``` user@user:~/mediacms$ make build-frontend -docker-compose -f docker-compose-dev.yaml exec frontend npm run dist +docker compose -f docker-compose-dev.yaml exec frontend npm run dist > mediacms-frontend@0.9.1 dist /home/mediacms.io/mediacms/frontend > mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist diff --git a/docs/developers_docs.md b/docs/developers_docs.md index 9947d855..1c26f734 100644 --- a/docs/developers_docs.md +++ b/docs/developers_docs.md @@ -17,7 +17,7 @@ to be written ## 3. API documentation API is documented using Swagger - checkout ot http://your_installation/swagger - example https://demo.mediacms.io/swagger/ -This page allows you to login to perform authenticated actions - it will also use your session if logged in. +This page allows you to login to perform authenticated actions - it will also use your session if logged in. An example of working with Python requests library: @@ -50,8 +50,8 @@ Checkout the [Code of conduct page](../CODE_OF_CONDUCT.md) if you want to contri To perform the Docker installation, follow instructions to install Docker + Docker compose (docs/Docker_Compose.md) and then build/start docker-compose-dev.yaml . This will run the frontend application on port 8088 on top of all other containers (including the Django web application on port 80) ``` -docker-compose -f docker-compose-dev.yaml build -docker-compose -f docker-compose-dev.yaml up +docker compose -f docker-compose-dev.yaml build +docker compose -f docker-compose-dev.yaml up ``` An `admin` user is created during the installation process. Its attributes are defined in `docker-compose-dev.yaml`: @@ -65,16 +65,16 @@ ADMIN_EMAIL: 'admin@localhost' Eg change `frontend/src/static/js/pages/HomePage.tsx` , dev application refreshes in a number of seconds (hot reloading) and I see the changes, once I'm happy I can run ``` -docker-compose -f docker-compose-dev.yaml exec -T frontend npm run dist +docker compose -f docker-compose-dev.yaml exec -T frontend npm run dist ``` -And then in order for the changes to be visible on the application while served through nginx, +And then in order for the changes to be visible on the application while served through nginx, ``` cp -r frontend/dist/static/* static/ ``` -POST calls: cannot be performed through the dev server, you have to make through the normal application (port 80) and then see changes on the dev application on port 8088. +POST calls: cannot be performed through the dev server, you have to make through the normal application (port 80) and then see changes on the dev application on port 8088. Make sure the urls are set on `frontend/.env` if different than localhost @@ -90,7 +90,7 @@ http://localhost:8088/manage-media.html manage_media After I make changes to the django application (eg make a change on `files/forms.py`) in order to see the changes I have to restart the web container ``` -docker-compose -f docker-compose-dev.yaml restart web +docker compose -f docker-compose-dev.yaml restart web ``` ## How video is transcoded @@ -113,7 +113,7 @@ there is also an experimental small service (not commited to the repo currently) When the Encode object is marked as success and chunk=False, and thus is available for download/stream, there is a task that gets started and saves an HLS version of the file (1 mp4-->x number of small .ts chunks). This would be FILES_C -This mechanism allows for workers that have access on the same filesystem (either localhost, or through a shared network filesystem, eg NFS/EFS) to work on the same time and produce results. +This mechanism allows for workers that have access on the same filesystem (either localhost, or through a shared network filesystem, eg NFS/EFS) to work on the same time and produce results. ## 6. Working with the automated tests @@ -122,19 +122,19 @@ This instructions assume that you're using the docker installation 1. start docker-compose ``` -docker-compose up +docker compose up ``` 2. Install the requirements on `requirements-dev.txt ` on web container (we'll use the web container for this) ``` -docker-compose exec -T web pip install -r requirements-dev.txt +docker compose exec -T web pip install -r requirements-dev.txt ``` 3. Now you can run the existing tests ``` -docker-compose exec --env TESTING=True -T web pytest +docker compose exec --env TESTING=True -T web pytest ``` The `TESTING=True` is passed for Django to be aware this is a testing environment (so that it runs Celery tasks as functions for example and not as background tasks, since Celery is not started in the case of pytest) @@ -143,13 +143,13 @@ The `TESTING=True` is passed for Django to be aware this is a testing environmen 4. You may try a single test, by specifying the path, for example ``` -docker-compose exec --env TESTING=True -T web pytest tests/test_fixtures.py +docker compose exec --env TESTING=True -T web pytest tests/test_fixtures.py ``` 5. You can also see the coverage ``` -docker-compose exec --env TESTING=True -T web pytest --cov=. --cov-report=html +docker compose exec --env TESTING=True -T web pytest --cov=. --cov-report=html ``` and of course...you are very welcome to help us increase it ;) diff --git a/docs/media_permissions.md b/docs/media_permissions.md new file mode 100644 index 00000000..be57cb04 --- /dev/null +++ b/docs/media_permissions.md @@ -0,0 +1,166 @@ +# Media Permissions in MediaCMS + +This document explains the permission system in MediaCMS, which controls who can view, edit, and manage media files. + +## Overview + +MediaCMS provides a flexible permission system that allows fine-grained control over media access. The system supports: + +1. **Basic permissions** - Public, private, and unlisted media +2. **User-specific permissions** - Direct permissions granted to specific users +3. **Role-Based Access Control (RBAC)** - Category-based permissions through group membership + +## Media States + +Every media file has a state that determines its basic visibility: + +- **Public** - Visible to everyone +- **Private** - Only visible to the owner and users with explicit permissions +- **Unlisted** - Not listed in public listings but accessible via direct link + + +## User Roles + +MediaCMS has several user roles that affect permissions: + +- **Regular User** - Can upload and manage their own media +- **Advanced User** - Additional capabilities (configurable) +- **MediaCMS Editor** - Can edit and review content across the platform +- **MediaCMS Manager** - Full management capabilities +- **Admin** - Complete system access + +## Direct Media Permissions + +The `MediaPermission` model allows granting specific permissions to individual users: + +### Permission Levels + +- **Viewer** - Can view the media even if it's private +- **Editor** - Can view and edit the media's metadata +- **Owner** - Full control, including deletion + +## Role-Based Access Control (RBAC) + +When RBAC is enabled (`USE_RBAC` setting), permissions can be managed through categories and groups: + +1. Categories can be marked as RBAC-controlled +2. Users are assigned to RBAC groups with specific roles +3. RBAC groups are associated with categories +4. Users inherit permissions to media in those categories based on their role + +### RBAC Roles + +- **Member** - Can view media in the category +- **Contributor** - Can view and edit media in the category +- **Manager** - Full control over media in the category + +## Permission Checking Methods + +The User model provides several methods to check permissions: + +```python +# From users/models.py +def has_member_access_to_media(self, media): + # Check if user can view the media + # ... + +def has_contributor_access_to_media(self, media): + # Check if user can edit the media + # ... + +def has_owner_access_to_media(self, media): + # Check if user has full control over the media + # ... +``` + +## How Permissions Are Applied + +When a user attempts to access media, the system checks permissions in this order: + +1. Is the media public? If yes, allow access. +2. Is the user the owner of the media? If yes, allow full access. +3. Does the user have direct permissions through MediaPermission? If yes, grant the corresponding access level. +4. If RBAC is enabled, does the user have access through category membership? If yes, grant the corresponding access level. +5. If none of the above, deny access. + +## Media Sharing + +Users can share media with others by: + +1. Making it public or unlisted +2. Granting direct permissions to specific users +3. Adding it to a category that's accessible to an RBAC group + +## Implementation Details + +### Media Listing + +When listing media, the system filters based on permissions: + +```python +# Simplified example from files/views/media.py +def _get_media_queryset(self, request, user=None): + # 1. Public media + listable_media = Media.objects.filter(listable=True) + + if not request.user.is_authenticated: + return listable_media + + # 2. User permissions for authenticated users + user_media = Media.objects.filter(permissions__user=request.user) + + # 3. RBAC for authenticated users + if getattr(settings, 'USE_RBAC', False): + rbac_categories = request.user.get_rbac_categories_as_member() + rbac_media = Media.objects.filter(category__in=rbac_categories) + + # Combine all accessible media + return listable_media.union(user_media, rbac_media) +``` + +### Permission Checking + +The system uses helper methods to check permissions: + +```python +# From users/models.py +def has_member_access_to_media(self, media): + # First check if user is the owner + if media.user == self: + return True + + # Then check RBAC permissions + if getattr(settings, 'USE_RBAC', False): + rbac_groups = RBACGroup.objects.filter( + memberships__user=self, + memberships__role__in=["member", "contributor", "manager"], + categories__in=media.category.all() + ).distinct() + if rbac_groups.exists(): + return True + + # Then check MediaShare permissions for any access + media_permission_exists = MediaPermission.objects.filter( + user=self, + media=media, + ).exists() + + return media_permission_exists +``` + +## Best Practices + +1. **Default to Private** - Consider setting new uploads to private by default +2. **Use Categories** - Organize media into categories for easier permission management +3. **RBAC for Teams** - Use RBAC for team collaboration scenarios +4. **Direct Permissions for Exceptions** - Use direct permissions for one-off sharing + +## Configuration + +The permission system can be configured through several settings: + +- `USE_RBAC` - Enable/disable Role-Based Access Control + +## Conclusion + +MediaCMS provides a flexible and powerful permission system that can accommodate various use cases, from simple personal media libraries to complex team collaboration scenarios with fine-grained access control. diff --git a/files/forms.py b/files/forms.py index 1988eb2f..c62cf71f 100644 --- a/files/forms.py +++ b/files/forms.py @@ -68,14 +68,18 @@ class MediaMetadataForm(forms.ModelForm): self.helper.form_method = 'post' self.helper.form_enctype = "multipart/form-data" self.helper.form_show_errors = False - self.helper.layout = Layout( + + layout_fields = [ CustomField('title'), CustomField('new_tags'), CustomField('add_date'), CustomField('description'), - CustomField('uploaded_poster'), CustomField('enable_comments'), - ) + ] + if self.instance.media_type != "image": + layout_fields.append(CustomField('uploaded_poster')) + + self.helper.layout = Layout(*layout_fields) if self.instance.media_type == "video": self.helper.layout.append(CustomField('thumbnail_time')) diff --git a/files/methods.py b/files/methods.py index 1cd5ea71..78502a58 100644 --- a/files/methods.py +++ b/files/methods.py @@ -567,3 +567,42 @@ def handle_video_chapters(media, chapters): video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters) return media.chapter_data + + +def change_media_owner(media_id, new_user): + """Change the owner of a media + + Args: + media_id: ID of the media to change owner + new_user: New user object to set as owner + + Returns: + Media object or None if media not found + """ + media = models.Media.objects.filter(id=media_id).first() + if not media: + return None + + # Change the owner + media.user = new_user + media.save(update_fields=["user"]) + + # Update any related permissions + media_permissions = models.MediaPermission.objects.filter(media=media) + for permission in media_permissions: + permission.owner_user = new_user + permission.save(update_fields=["owner_user"]) + + return media + + +def copy_media(media_id): + """Create a copy of a media + + Args: + media_id: ID of the media to copy + + Returns: + None + """ + pass diff --git a/files/migrations/0011_mediapermission.py b/files/migrations/0011_mediapermission.py new file mode 100644 index 00000000..24c900bb --- /dev/null +++ b/files/migrations/0011_mediapermission.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.6 on 2025-07-08 19:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('files', '0010_alter_encodeprofile_resolution'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MediaPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('permission', models.CharField(choices=[('viewer', 'Viewer'), ('editor', 'Editor'), ('owner', 'Owner')], max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='files.media')), + ('owner_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='granted_permissions', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'media')}, + }, + ), + ] diff --git a/files/models/__init__.py b/files/models/__init__.py new file mode 100644 index 00000000..d58009d1 --- /dev/null +++ b/files/models/__init__.py @@ -0,0 +1,25 @@ +# Import all models for backward compatibility +from .category import Category, Tag # noqa: F401 +from .comment import Comment # noqa: F401 +from .encoding import EncodeProfile, Encoding # noqa: F401 +from .license import License # noqa: F401 +from .media import Media, MediaPermission # noqa: F401 +from .playlist import Playlist, PlaylistMedia # noqa: F401 +from .rating import Rating, RatingCategory # noqa: F401 +from .subtitle import Language, Subtitle # noqa: F401 +from .utils import CODECS # noqa: F401 +from .utils import ENCODE_EXTENSIONS # noqa: F401 +from .utils import ENCODE_EXTENSIONS_KEYS # noqa: F401 +from .utils import ENCODE_RESOLUTIONS # noqa: F401 +from .utils import ENCODE_RESOLUTIONS_KEYS # noqa: F401 +from .utils import MEDIA_ENCODING_STATUS # noqa: F401 +from .utils import MEDIA_STATES # noqa: F401 +from .utils import MEDIA_TYPES_SUPPORTED # noqa: F401 +from .utils import category_thumb_path # noqa: F401 +from .utils import encoding_media_file_path # noqa: F401 +from .utils import generate_uid # noqa: F401 +from .utils import original_media_file_path # noqa: F401 +from .utils import original_thumbnail_file_path # noqa: F401 +from .utils import subtitles_file_path # noqa: F401 +from .utils import validate_rating # noqa: F401 +from .video_data import VideoChapterData, VideoTrimRequest # noqa: F401 diff --git a/files/models/category.py b/files/models/category.py new file mode 100644 index 00000000..347b17f7 --- /dev/null +++ b/files/models/category.py @@ -0,0 +1,156 @@ +from django.db import models +from django.urls import reverse +from django.utils.html import strip_tags +from imagekit.models import ProcessedImageField +from imagekit.processors import ResizeToFit + +from .. import helpers +from .utils import category_thumb_path, generate_uid + + +class Category(models.Model): + """A Category base model""" + + uid = models.CharField(unique=True, max_length=36, default=generate_uid) + + add_date = models.DateTimeField(auto_now_add=True) + + title = models.CharField(max_length=100, db_index=True) + + description = models.TextField(blank=True) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True) + + is_global = models.BooleanField(default=False, help_text="global categories or user specific") + + media_count = models.IntegerField(default=0, help_text="number of media") + + thumbnail = ProcessedImageField( + upload_to=category_thumb_path, + processors=[ResizeToFit(width=344, height=None)], + format="JPEG", + options={"quality": 85}, + blank=True, + ) + + listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings") + + is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups') + + identity_provider = models.ForeignKey( + 'socialaccount.SocialApp', + blank=True, + null=True, + on_delete=models.CASCADE, + related_name='categories', + help_text='If category is related with a specific Identity Provider', + verbose_name='IDP Config Name', + ) + + def __str__(self): + return self.title + + class Meta: + ordering = ["title"] + verbose_name_plural = "Categories" + + def get_absolute_url(self): + return f"{reverse('search')}?c={self.title}" + + def update_category_media(self): + """Set media_count""" + + # Always set number of Category the total number of media + # Depending on how RBAC is set and Permissions etc it is + # possible that users won't see all media in a Category + # but it's worth to handle this on the UI level + # (eg through a message that says that you see only files you have permissions to see) + + self.media_count = Media.objects.filter(category=self).count() + self.save(update_fields=["media_count"]) + + # OLD logic + # if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category: + # self.media_count = Media.objects.filter(category=self).count() + # else: + # self.media_count = Media.objects.filter(listable=True, category=self).count() + + self.save(update_fields=["media_count"]) + return True + + @property + def thumbnail_url(self): + """Return thumbnail for category + prioritize processed value of listings_thumbnail + then thumbnail + """ + + if self.thumbnail: + return helpers.url_from_path(self.thumbnail.path) + + if self.listings_thumbnail: + return self.listings_thumbnail + + if Media.objects.filter(category=self, state="public").exists(): + media = Media.objects.filter(category=self, state="public").order_by("-views").first() + if media: + return media.thumbnail_url + + return None + + def save(self, *args, **kwargs): + strip_text_items = ["title", "description"] + for item in strip_text_items: + setattr(self, item, strip_tags(getattr(self, item, None))) + super(Category, self).save(*args, **kwargs) + + +class Tag(models.Model): + """A Tag model""" + + title = models.CharField(max_length=100, unique=True, db_index=True) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True) + + media_count = models.IntegerField(default=0, help_text="number of media") + + listings_thumbnail = models.CharField( + max_length=400, + blank=True, + null=True, + help_text="Thumbnail to show on listings", + db_index=True, + ) + + def __str__(self): + return self.title + + class Meta: + ordering = ["title"] + + def get_absolute_url(self): + return f"{reverse('search')}?t={self.title}" + + def update_tag_media(self): + self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count() + self.save(update_fields=["media_count"]) + return True + + def save(self, *args, **kwargs): + self.title = helpers.get_alphanumeric_only(self.title) + self.title = self.title[:99] + super(Tag, self).save(*args, **kwargs) + + @property + def thumbnail_url(self): + if self.listings_thumbnail: + return self.listings_thumbnail + media = Media.objects.filter(tags=self, state="public").order_by("-views").first() + if media: + return media.thumbnail_url + + return None + + +# Import Media to avoid circular imports +from .media import Media # noqa diff --git a/files/models/comment.py b/files/models/comment.py new file mode 100644 index 00000000..6ba2830b --- /dev/null +++ b/files/models/comment.py @@ -0,0 +1,46 @@ +import uuid + +from django.conf import settings +from django.db import models +from django.urls import reverse +from django.utils.html import strip_tags +from mptt.models import MPTTModel, TreeForeignKey + + +class Comment(MPTTModel): + """Comments model""" + + add_date = models.DateTimeField(auto_now_add=True) + + media = models.ForeignKey("Media", on_delete=models.CASCADE, db_index=True, related_name="comments") + + parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children") + + text = models.TextField(help_text="text") + + uid = models.UUIDField(unique=True, default=uuid.uuid4) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True) + + class MPTTMeta: + order_insertion_by = ["add_date"] + + def __str__(self): + return f"On {self.media.title} by {self.user.username}" + + def save(self, *args, **kwargs): + strip_text_items = ["text"] + for item in strip_text_items: + setattr(self, item, strip_tags(getattr(self, item, None))) + + if self.text: + self.text = self.text[: settings.MAX_CHARS_FOR_COMMENT] + + super(Comment, self).save(*args, **kwargs) + + def get_absolute_url(self): + return f"{reverse('get_media')}?m={self.media.friendly_token}" + + @property + def media_url(self): + return self.get_absolute_url() diff --git a/files/models/encoding.py b/files/models/encoding.py new file mode 100644 index 00000000..ec209659 --- /dev/null +++ b/files/models/encoding.py @@ -0,0 +1,303 @@ +import json +import tempfile + +from django.conf import settings +from django.core.files import File +from django.db import models +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver +from django.urls import reverse + +from .. import helpers +from .utils import ( + CODECS, + ENCODE_EXTENSIONS, + ENCODE_RESOLUTIONS, + MEDIA_ENCODING_STATUS, + encoding_media_file_path, +) + + +class EncodeProfile(models.Model): + """Encode Profile model + keeps information for each profile + """ + + name = models.CharField(max_length=90) + + extension = models.CharField(max_length=10, choices=ENCODE_EXTENSIONS) + + resolution = models.IntegerField(choices=ENCODE_RESOLUTIONS, blank=True, null=True) + + codec = models.CharField(max_length=10, choices=CODECS, blank=True, null=True) + + description = models.TextField(blank=True, help_text="description") + + active = models.BooleanField(default=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ["resolution"] + + +class Encoding(models.Model): + """Encoding Media Instances""" + + add_date = models.DateTimeField(auto_now_add=True) + + commands = models.TextField(blank=True, help_text="commands run") + + chunk = models.BooleanField(default=False, db_index=True, help_text="is chunk?") + + chunk_file_path = models.CharField(max_length=400, blank=True) + + chunks_info = models.TextField(blank=True) + + logs = models.TextField(blank=True) + + md5sum = models.CharField(max_length=50, blank=True, null=True) + + media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="encodings") + + media_file = models.FileField("encoding file", upload_to=encoding_media_file_path, blank=True, max_length=500) + + profile = models.ForeignKey(EncodeProfile, on_delete=models.CASCADE) + + progress = models.PositiveSmallIntegerField(default=0) + + update_date = models.DateTimeField(auto_now=True) + + retries = models.IntegerField(default=0) + + size = models.CharField(max_length=20, blank=True) + + status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending") + + temp_file = models.CharField(max_length=400, blank=True) + + task_id = models.CharField(max_length=100, blank=True) + + total_run_time = models.IntegerField(default=0) + + worker = models.CharField(max_length=100, blank=True) + + @property + def media_encoding_url(self): + if self.media_file: + return helpers.url_from_path(self.media_file.path) + return None + + @property + def media_chunk_url(self): + if self.chunk_file_path: + return helpers.url_from_path(self.chunk_file_path) + return None + + def save(self, *args, **kwargs): + if self.media_file: + cmd = ["stat", "-c", "%s", self.media_file.path] + stdout = helpers.run_command(cmd).get("out") + if stdout: + size = int(stdout.strip()) + self.size = helpers.show_file_size(size) + if self.chunk_file_path and not self.md5sum: + cmd = ["md5sum", self.chunk_file_path] + stdout = helpers.run_command(cmd).get("out") + if stdout: + md5sum = stdout.strip().split()[0] + self.md5sum = md5sum + + super(Encoding, self).save(*args, **kwargs) + + def update_size_without_save(self): + """Update the size of an encoding without saving to avoid calling signals""" + if self.media_file: + cmd = ["stat", "-c", "%s", self.media_file.path] + stdout = helpers.run_command(cmd).get("out") + if stdout: + size = int(stdout.strip()) + size = helpers.show_file_size(size) + Encoding.objects.filter(pk=self.pk).update(size=size) + return True + return False + + def set_progress(self, progress, commit=True): + if isinstance(progress, int): + if 0 <= progress <= 100: + self.progress = progress + # save object with filter update + # to avoid calling signals + Encoding.objects.filter(pk=self.pk).update(progress=progress) + return True + return False + + def __str__(self): + return f"{self.profile.name}-{self.media.title}" + + def get_absolute_url(self): + return reverse("api_get_encoding", kwargs={"encoding_id": self.id}) + + +@receiver(post_save, sender=Encoding) +def encoding_file_save(sender, instance, created, **kwargs): + """Performs actions on encoding file delete + For example, if encoding is a chunk file, with encoding_status success, + perform a check if this is the final chunk file of a media, then + concatenate chunks, create final encoding file and delete chunks + """ + + if instance.chunk and instance.status == "success": + # a chunk got completed + + # check if all chunks are OK + # then concatenate to new Encoding - and remove chunks + # this should run only once! + if instance.media_file: + try: + orig_chunks = json.loads(instance.chunks_info).keys() + except BaseException: + instance.delete() + return False + + chunks = Encoding.objects.filter( + media=instance.media, + profile=instance.profile, + chunks_info=instance.chunks_info, + chunk=True, + ).order_by("add_date") + + complete = True + + # perform validation, make sure everything is there + for chunk in orig_chunks: + if not chunks.filter(chunk_file_path=chunk): + complete = False + break + + for chunk in chunks: + if not (chunk.media_file and chunk.media_file.path): + complete = False + break + + if complete: + # concatenate chunks and create final encoding file + chunks_paths = [f.media_file.path for f in chunks] + + with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: + seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir) + tf = helpers.create_temp_file(suffix=f".{instance.profile.extension}", dir=temp_dir) + with open(seg_file, "w") as ff: + for f in chunks_paths: + ff.write(f"file {f}\n") + cmd = [ + settings.FFMPEG_COMMAND, + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + seg_file, + "-c", + "copy", + "-pix_fmt", + "yuv420p", + "-movflags", + "faststart", + tf, + ] + stdout = helpers.run_command(cmd) + + encoding = Encoding( + media=instance.media, + profile=instance.profile, + status="success", + progress=100, + ) + all_logs = "\n".join([st.logs for st in chunks]) + encoding.logs = f"{chunks_paths}\n{stdout}\n{all_logs}" + workers = list(set([st.worker for st in chunks])) + encoding.worker = json.dumps({"workers": workers}) + + start_date = min([st.add_date for st in chunks]) + end_date = max([st.update_date for st in chunks]) + encoding.total_run_time = (end_date - start_date).seconds + encoding.save() + + with open(tf, "rb") as f: + myfile = File(f) + output_name = f"{helpers.get_file_name(instance.media.media_file.path)}.{instance.profile.extension}" + encoding.media_file.save(content=myfile, name=output_name) + + # encoding is saved, deleting chunks + # and any other encoding that might exist + # first perform one last validation + # to avoid that this is run twice + if ( + len(orig_chunks) + == Encoding.objects.filter( # noqa + media=instance.media, + profile=instance.profile, + chunks_info=instance.chunks_info, + ).count() + ): + # if two chunks are finished at the same time, this + # will be changed + who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id) + who.delete() + else: + encoding.delete() + if not Encoding.objects.filter(chunks_info=instance.chunks_info): + # TODO: in case of remote workers, files should be deleted + # example + # for worker in workers: + # for chunk in json.loads(instance.chunks_info).keys(): + # remove_media_file.delay(media_file=chunk) + for chunk in json.loads(instance.chunks_info).keys(): + helpers.rm_file(chunk) + instance.media.post_encode_actions(encoding=instance, action="add") + + elif instance.chunk and instance.status == "fail": + encoding = Encoding(media=instance.media, profile=instance.profile, status="fail", progress=100) + + chunks = Encoding.objects.filter(media=instance.media, chunks_info=instance.chunks_info, chunk=True).order_by("add_date") + + chunks_paths = [f.media_file.path for f in chunks] + + all_logs = "\n".join([st.logs for st in chunks]) + encoding.logs = f"{chunks_paths}\n{all_logs}" + workers = list(set([st.worker for st in chunks])) + encoding.worker = json.dumps({"workers": workers}) + start_date = min([st.add_date for st in chunks]) + end_date = max([st.update_date for st in chunks]) + encoding.total_run_time = (end_date - start_date).seconds + encoding.save() + + who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id) + + who.delete() + # TODO: merge with above if, do not repeat code + else: + if instance.status in ["fail", "success"]: + instance.media.post_encode_actions(encoding=instance, action="add") + + encodings = set([encoding.status for encoding in Encoding.objects.filter(media=instance.media)]) + if ("running" in encodings) or ("pending" in encodings): + return + + +@receiver(post_delete, sender=Encoding) +def encoding_file_delete(sender, instance, **kwargs): + """ + Deletes file from filesystem + when corresponding `Encoding` object is deleted. + """ + + if instance.media_file: + helpers.rm_file(instance.media_file.path) + if not instance.chunk: + instance.media.post_encode_actions(encoding=instance, action="delete") + # delete local chunks, and remote chunks + media file. Only when the + # last encoding of a media is complete diff --git a/files/models/license.py b/files/models/license.py new file mode 100644 index 00000000..dafe74f0 --- /dev/null +++ b/files/models/license.py @@ -0,0 +1,11 @@ +from django.db import models + + +class License(models.Model): + """A Base license model to be used in Media""" + + title = models.CharField(max_length=100, unique=True) + description = models.TextField(blank=True) + + def __str__(self): + return self.title diff --git a/files/models.py b/files/models/media.py similarity index 55% rename from files/models.py rename to files/models/media.py index 1729094b..03e08b7e 100644 --- a/files/models.py +++ b/files/models/media.py @@ -3,15 +3,12 @@ import json import logging import os import random -import re -import tempfile import uuid import m3u8 from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField -from django.core.exceptions import ValidationError from django.core.files import File from django.db import models from django.db.models import Func, Value @@ -19,108 +16,25 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de from django.dispatch import receiver from django.urls import reverse from django.utils import timezone -from django.utils.crypto import get_random_string from django.utils.html import strip_tags from imagekit.models import ProcessedImageField from imagekit.processors import ResizeToFit -from mptt.models import MPTTModel, TreeForeignKey -from . import helpers -from .stop_words import STOP_WORDS +from .. import helpers +from ..stop_words import STOP_WORDS +from .encoding import EncodeProfile, Encoding +from .utils import ( + ENCODE_RESOLUTIONS_KEYS, + MEDIA_ENCODING_STATUS, + MEDIA_STATES, + MEDIA_TYPES_SUPPORTED, + original_media_file_path, + original_thumbnail_file_path, +) +from .video_data import VideoTrimRequest logger = logging.getLogger(__name__) -RE_TIMECODE = re.compile(r"(\d+:\d+:\d+.\d+)") - -# this is used by Media and Encoding models -# reflects media encoding status for objects -MEDIA_ENCODING_STATUS = ( - ("pending", "Pending"), - ("running", "Running"), - ("fail", "Fail"), - ("success", "Success"), -) - -# the media state of a Media object -# this is set by default according to the portal workflow -MEDIA_STATES = ( - ("private", "Private"), - ("public", "Public"), - ("unlisted", "Unlisted"), -) - -# each uploaded Media gets a media_type hint -# by helpers.get_file_type - -MEDIA_TYPES_SUPPORTED = ( - ("video", "Video"), - ("image", "Image"), - ("pdf", "Pdf"), - ("audio", "Audio"), -) - -ENCODE_EXTENSIONS = ( - ("mp4", "mp4"), - ("webm", "webm"), - ("gif", "gif"), -) - -ENCODE_RESOLUTIONS = ( - (2160, "2160"), - (1440, "1440"), - (1080, "1080"), - (720, "720"), - (480, "480"), - (360, "360"), - (240, "240"), - (144, "144"), -) - -CODECS = ( - ("h265", "h265"), - ("h264", "h264"), - ("vp9", "vp9"), -) - -ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS] -ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS] - - -def generate_uid(): - return get_random_string(length=16) - - -def original_media_file_path(instance, filename): - """Helper function to place original media file""" - file_name = f"{instance.uid.hex}.{helpers.get_file_name(filename)}" - return settings.MEDIA_UPLOAD_DIR + f"user/{instance.user.username}/{file_name}" - - -def encoding_media_file_path(instance, filename): - """Helper function to place encoded media file""" - - file_name = f"{instance.media.uid.hex}.{helpers.get_file_name(filename)}" - return settings.MEDIA_ENCODING_DIR + f"{instance.profile.id}/{instance.media.user.username}/{file_name}" - - -def original_thumbnail_file_path(instance, filename): - """Helper function to place original media thumbnail file""" - - return settings.THUMBNAIL_UPLOAD_DIR + f"user/{instance.user.username}/{filename}" - - -def subtitles_file_path(instance, filename): - """Helper function to place subtitle file""" - - return settings.SUBTITLES_UPLOAD_DIR + f"user/{instance.media.user.username}/{filename}" - - -def category_thumb_path(instance, filename): - """Helper function to place category thumbnail file""" - - file_name = f"{instance.uid}.{helpers.get_file_name(filename)}" - return settings.MEDIA_UPLOAD_DIR + f"categories/{file_name}" - class Media(models.Model): """The most important model for MediaCMS""" @@ -138,7 +52,6 @@ class Media(models.Model): null=True, help_text="Media can exist in one or no Channels", ) - description = models.TextField(blank=True) dislikes = models.IntegerField(default=0) @@ -566,7 +479,7 @@ class Media(models.Model): To be used on the video player """ - from . import tasks + from .. import tasks tasks.produce_sprite_from_video.delay(self.friendly_token) return True @@ -582,7 +495,7 @@ class Media(models.Model): profiles = EncodeProfile.objects.filter(active=True) profiles = list(profiles) - from . import tasks + from .. import tasks # attempt to break media file in chunks if self.duration > settings.CHUNKIZE_VIDEO_DURATION and chunkize: @@ -638,7 +551,7 @@ class Media(models.Model): self.save(update_fields=["encoding_status", "listable", "preview_file_path"]) if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk: - from . import tasks + from .. import tasks tasks.create_hls.delay(self.friendly_token) @@ -993,585 +906,26 @@ class Media(models.Model): return data -class License(models.Model): - """A Base license model to be used in Media""" +class MediaPermission(models.Model): + """Model to store user permissions for media""" - title = models.CharField(max_length=100, unique=True) - description = models.TextField(blank=True) - - def __str__(self): - return self.title - - -class Category(models.Model): - """A Category base model""" - - uid = models.CharField(unique=True, max_length=36, default=generate_uid) - - add_date = models.DateTimeField(auto_now_add=True) - - title = models.CharField(max_length=100, db_index=True) - - description = models.TextField(blank=True) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True) - - is_global = models.BooleanField(default=False, help_text="global categories or user specific") - - media_count = models.IntegerField(default=0, help_text="number of media") - - thumbnail = ProcessedImageField( - upload_to=category_thumb_path, - processors=[ResizeToFit(width=344, height=None)], - format="JPEG", - options={"quality": 85}, - blank=True, + PERMISSION_CHOICES = ( + ("viewer", "Viewer"), + ("editor", "Editor"), + ("owner", "Owner"), ) - listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings") - - is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups') - - identity_provider = models.ForeignKey( - 'socialaccount.SocialApp', - blank=True, - null=True, - on_delete=models.CASCADE, - related_name='categories', - help_text='If category is related with a specific Identity Provider', - verbose_name='IDP Config Name', - ) - - def __str__(self): - return self.title + owner_user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='granted_permissions') + user = models.ForeignKey('users.User', on_delete=models.CASCADE) + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='permissions') + permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) class Meta: - ordering = ["title"] - verbose_name_plural = "Categories" - - def get_absolute_url(self): - return f"{reverse('search')}?c={self.title}" - - def update_category_media(self): - """Set media_count""" - - if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category: - self.media_count = Media.objects.filter(category=self).count() - else: - self.media_count = Media.objects.filter(listable=True, category=self).count() - - self.save(update_fields=["media_count"]) - return True - - @property - def thumbnail_url(self): - """Return thumbnail for category - prioritize processed value of listings_thumbnail - then thumbnail - """ - - if self.listings_thumbnail: - return self.listings_thumbnail - if self.thumbnail: - return helpers.url_from_path(self.thumbnail.path) - - media = Media.objects.filter(category=self, state="public").order_by("-views").first() - if media: - return media.thumbnail_url - - return None - - def save(self, *args, **kwargs): - strip_text_items = ["title", "description"] - for item in strip_text_items: - setattr(self, item, strip_tags(getattr(self, item, None))) - super(Category, self).save(*args, **kwargs) - - -class Tag(models.Model): - """A Tag model""" - - title = models.CharField(max_length=100, unique=True, db_index=True) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True) - - media_count = models.IntegerField(default=0, help_text="number of media") - - listings_thumbnail = models.CharField( - max_length=400, - blank=True, - null=True, - help_text="Thumbnail to show on listings", - db_index=True, - ) + unique_together = ('user', 'media') def __str__(self): - return self.title - - class Meta: - ordering = ["title"] - - def get_absolute_url(self): - return f"{reverse('search')}?t={self.title}" - - def update_tag_media(self): - self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count() - self.save(update_fields=["media_count"]) - return True - - def save(self, *args, **kwargs): - self.title = helpers.get_alphanumeric_only(self.title) - self.title = self.title[:99] - super(Tag, self).save(*args, **kwargs) - - @property - def thumbnail_url(self): - if self.listings_thumbnail: - return self.listings_thumbnail - media = Media.objects.filter(tags=self, state="public").order_by("-views").first() - if media: - return media.thumbnail_url - - return None - - -class EncodeProfile(models.Model): - """Encode Profile model - keeps information for each profile - """ - - name = models.CharField(max_length=90) - - extension = models.CharField(max_length=10, choices=ENCODE_EXTENSIONS) - - resolution = models.IntegerField(choices=ENCODE_RESOLUTIONS, blank=True, null=True) - - codec = models.CharField(max_length=10, choices=CODECS, blank=True, null=True) - - description = models.TextField(blank=True, help_text="description") - - active = models.BooleanField(default=True) - - def __str__(self): - return self.name - - class Meta: - ordering = ["resolution"] - - -class Encoding(models.Model): - """Encoding Media Instances""" - - add_date = models.DateTimeField(auto_now_add=True) - - commands = models.TextField(blank=True, help_text="commands run") - - chunk = models.BooleanField(default=False, db_index=True, help_text="is chunk?") - - chunk_file_path = models.CharField(max_length=400, blank=True) - - chunks_info = models.TextField(blank=True) - - logs = models.TextField(blank=True) - - md5sum = models.CharField(max_length=50, blank=True, null=True) - - media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="encodings") - - media_file = models.FileField("encoding file", upload_to=encoding_media_file_path, blank=True, max_length=500) - - profile = models.ForeignKey(EncodeProfile, on_delete=models.CASCADE) - - progress = models.PositiveSmallIntegerField(default=0) - - update_date = models.DateTimeField(auto_now=True) - - retries = models.IntegerField(default=0) - - size = models.CharField(max_length=20, blank=True) - - status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending") - - temp_file = models.CharField(max_length=400, blank=True) - - task_id = models.CharField(max_length=100, blank=True) - - total_run_time = models.IntegerField(default=0) - - worker = models.CharField(max_length=100, blank=True) - - @property - def media_encoding_url(self): - if self.media_file: - return helpers.url_from_path(self.media_file.path) - return None - - @property - def media_chunk_url(self): - if self.chunk_file_path: - return helpers.url_from_path(self.chunk_file_path) - return None - - def save(self, *args, **kwargs): - if self.media_file: - cmd = ["stat", "-c", "%s", self.media_file.path] - stdout = helpers.run_command(cmd).get("out") - if stdout: - size = int(stdout.strip()) - self.size = helpers.show_file_size(size) - if self.chunk_file_path and not self.md5sum: - cmd = ["md5sum", self.chunk_file_path] - stdout = helpers.run_command(cmd).get("out") - if stdout: - md5sum = stdout.strip().split()[0] - self.md5sum = md5sum - - super(Encoding, self).save(*args, **kwargs) - - def update_size_without_save(self): - """Update the size of an encoding without saving to avoid calling signals""" - if self.media_file: - cmd = ["stat", "-c", "%s", self.media_file.path] - stdout = helpers.run_command(cmd).get("out") - if stdout: - size = int(stdout.strip()) - size = helpers.show_file_size(size) - Encoding.objects.filter(pk=self.pk).update(size=size) - return True - return False - - def set_progress(self, progress, commit=True): - if isinstance(progress, int): - if 0 <= progress <= 100: - self.progress = progress - # save object with filter update - # to avoid calling signals - Encoding.objects.filter(pk=self.pk).update(progress=progress) - return True - return False - - def __str__(self): - return f"{self.profile.name}-{self.media.title}" - - def get_absolute_url(self): - return reverse("api_get_encoding", kwargs={"encoding_id": self.id}) - - -class Language(models.Model): - """Language model - to be used with Subtitles - """ - - code = models.CharField(max_length=12, help_text="language code") - - title = models.CharField(max_length=100, help_text="language code") - - class Meta: - ordering = ["id"] - - def __str__(self): - return f"{self.code}-{self.title}" - - -class Subtitle(models.Model): - """Subtitles model""" - - language = models.ForeignKey(Language, on_delete=models.CASCADE) - - media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="subtitles") - - subtitle_file = models.FileField( - "Subtitle/CC file", - help_text="File has to be WebVTT format", - upload_to=subtitles_file_path, - max_length=500, - ) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE) - - class Meta: - ordering = ["language__title"] - - def __str__(self): - return f"{self.media.title}-{self.language.title}" - - def get_absolute_url(self): - return f"{reverse('edit_subtitle')}?id={self.id}" - - @property - def url(self): - return self.get_absolute_url() - - def convert_to_srt(self): - input_path = self.subtitle_file.path - with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname: - pysub = settings.PYSUBS_COMMAND - - cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname] - stdout = helpers.run_command(cmd) - - list_of_files = os.listdir(tmpdirname) - if list_of_files: - subtitles_file = os.path.join(tmpdirname, list_of_files[0]) - cmd = ["cp", subtitles_file, input_path] - stdout = helpers.run_command(cmd) # noqa - else: - raise Exception("Could not convert to srt") - return True - - -class RatingCategory(models.Model): - """Rating Category - Facilitate user ratings. - One or more rating categories per Category can exist - will be shown to the media if they are enabled - """ - - description = models.TextField(blank=True) - - enabled = models.BooleanField(default=True) - - title = models.CharField(max_length=200, unique=True, db_index=True) - - class Meta: - verbose_name_plural = "Rating Categories" - - def __str__(self): - return f"{self.title}" - - -def validate_rating(value): - if -1 >= value or value > 5: - raise ValidationError("score has to be between 0 and 5") - - -class Rating(models.Model): - """User Rating""" - - add_date = models.DateTimeField(auto_now_add=True) - - media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="ratings") - - rating_category = models.ForeignKey(RatingCategory, on_delete=models.CASCADE) - - score = models.IntegerField(validators=[validate_rating]) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE) - - class Meta: - verbose_name_plural = "Ratings" - indexes = [ - models.Index(fields=["user", "media"]), - ] - unique_together = ("user", "media", "rating_category") - - def __str__(self): - return f"{self.user.username}, rate for {self.media.title} for category {self.rating_category.title}" - - -class Playlist(models.Model): - """Playlists model""" - - add_date = models.DateTimeField(auto_now_add=True, db_index=True) - - description = models.TextField(blank=True, help_text="description") - - friendly_token = models.CharField(blank=True, max_length=12, db_index=True) - - media = models.ManyToManyField(Media, through="playlistmedia", blank=True) - - title = models.CharField(max_length=100, db_index=True) - - uid = models.UUIDField(unique=True, default=uuid.uuid4) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True, related_name="playlists") - - def __str__(self): - return self.title - - @property - def media_count(self): - return self.media.filter(listable=True).count() - - def get_absolute_url(self, api=False): - if api: - return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token}) - else: - return reverse("get_playlist", kwargs={"friendly_token": self.friendly_token}) - - @property - def url(self): - return self.get_absolute_url() - - @property - def api_url(self): - return self.get_absolute_url(api=True) - - def user_thumbnail_url(self): - if self.user.logo: - return helpers.url_from_path(self.user.logo.path) - return None - - def set_ordering(self, media, ordering): - if media not in self.media.all(): - return False - pm = PlaylistMedia.objects.filter(playlist=self, media=media).first() - if pm and isinstance(ordering, int) and 0 < ordering: - pm.ordering = ordering - pm.save() - return True - return False - - def save(self, *args, **kwargs): - strip_text_items = ["title", "description"] - for item in strip_text_items: - setattr(self, item, strip_tags(getattr(self, item, None))) - self.title = self.title[:99] - - if not self.friendly_token: - while True: - friendly_token = helpers.produce_friendly_token() - if not Playlist.objects.filter(friendly_token=friendly_token): - self.friendly_token = friendly_token - break - super(Playlist, self).save(*args, **kwargs) - - @property - def thumbnail_url(self): - pm = self.playlistmedia_set.filter(media__listable=True).first() - if pm and pm.media.thumbnail: - return helpers.url_from_path(pm.media.thumbnail.path) - return None - - -class PlaylistMedia(models.Model): - """Helper model to store playlist specific media""" - - action_date = models.DateTimeField(auto_now=True) - - media = models.ForeignKey(Media, on_delete=models.CASCADE) - - playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE) - - ordering = models.IntegerField(default=1) - - class Meta: - ordering = ["ordering", "-action_date"] - - -class Comment(MPTTModel): - """Comments model""" - - add_date = models.DateTimeField(auto_now_add=True) - - media = models.ForeignKey(Media, on_delete=models.CASCADE, db_index=True, related_name="comments") - - parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children") - - text = models.TextField(help_text="text") - - uid = models.UUIDField(unique=True, default=uuid.uuid4) - - user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True) - - class MPTTMeta: - order_insertion_by = ["add_date"] - - def __str__(self): - return f"On {self.media.title} by {self.user.username}" - - def save(self, *args, **kwargs): - strip_text_items = ["text"] - for item in strip_text_items: - setattr(self, item, strip_tags(getattr(self, item, None))) - - if self.text: - self.text = self.text[: settings.MAX_CHARS_FOR_COMMENT] - - super(Comment, self).save(*args, **kwargs) - - def get_absolute_url(self): - return f"{reverse('get_media')}?m={self.media.friendly_token}" - - @property - def media_url(self): - return self.get_absolute_url() - - -class VideoChapterData(models.Model): - data = models.JSONField(null=False, blank=False, help_text="Chapter data") - media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters') - - class Meta: - unique_together = ['media'] - - def save(self, *args, **kwargs): - from . import tasks - - is_new = self.pk is None - if is_new or (not is_new and self._check_data_changed()): - super().save(*args, **kwargs) - tasks.produce_video_chapters.delay(self.pk) - else: - super().save(*args, **kwargs) - - def _check_data_changed(self): - if self.pk: - old_instance = VideoChapterData.objects.get(pk=self.pk) - return old_instance.data != self.data - return False - - @property - def chapter_data(self): - # ensure response is consistent - data = [] - for item in self.data: - if item.get("start") and item.get("title"): - thumbnail = item.get("thumbnail") - if thumbnail: - thumbnail = helpers.url_from_path(thumbnail) - else: - thumbnail = "static/images/chapter_default.jpg" - data.append( - { - "start": item.get("start"), - "title": item.get("title"), - "thumbnail": thumbnail, - } - ) - return data - - -class VideoTrimRequest(models.Model): - """Model to handle video trimming requests""" - - VIDEO_TRIM_STATUS = ( - ("initial", "Initial"), - ("running", "Running"), - ("success", "Success"), - ("fail", "Fail"), - ) - - VIDEO_ACTION_CHOICES = ( - ("replace", "Replace Original"), - ("save_new", "Save as New"), - ("create_segments", "Create Segments"), - ) - - TRIM_STYLE_CHOICES = ( - ("no_encoding", "No Encoding"), - ("precise", "Precise"), - ) - - media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests') - status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial") - add_date = models.DateTimeField(auto_now_add=True) - video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES) - media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding") - timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming") - - def __str__(self): - return f"Trim request for {self.media.title} ({self.status})" + return f"{self.user.username} - {self.media.title} ({self.permission})" @receiver(post_save, sender=Media) @@ -1585,7 +939,7 @@ def media_save(sender, instance, created, **kwargs): return False if created: - from .methods import notify_users + from ..methods import notify_users instance.media_init() notify_users(friendly_token=instance.friendly_token, action="media_added") @@ -1616,11 +970,6 @@ def media_file_pre_delete(sender, instance, **kwargs): tag.update_tag_media() -@receiver(post_delete, sender=VideoChapterData) -def videochapterdata_delete(sender, instance, **kwargs): - helpers.rm_dir(instance.media.video_chapters_folder) - - @receiver(post_delete, sender=Media) def media_file_delete(sender, instance, **kwargs): """ @@ -1661,166 +1010,3 @@ def media_m2m(sender, instance, **kwargs): if instance.tags.all(): for tag in instance.tags.all(): tag.update_tag_media() - - -@receiver(post_save, sender=Encoding) -def encoding_file_save(sender, instance, created, **kwargs): - """Performs actions on encoding file delete - For example, if encoding is a chunk file, with encoding_status success, - perform a check if this is the final chunk file of a media, then - concatenate chunks, create final encoding file and delete chunks - """ - - if instance.chunk and instance.status == "success": - # a chunk got completed - - # check if all chunks are OK - # then concatenate to new Encoding - and remove chunks - # this should run only once! - if instance.media_file: - try: - orig_chunks = json.loads(instance.chunks_info).keys() - except BaseException: - instance.delete() - return False - - chunks = Encoding.objects.filter( - media=instance.media, - profile=instance.profile, - chunks_info=instance.chunks_info, - chunk=True, - ).order_by("add_date") - - complete = True - - # perform validation, make sure everything is there - for chunk in orig_chunks: - if not chunks.filter(chunk_file_path=chunk): - complete = False - break - - for chunk in chunks: - if not (chunk.media_file and chunk.media_file.path): - complete = False - break - - if complete: - # concatenate chunks and create final encoding file - chunks_paths = [f.media_file.path for f in chunks] - - with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: - seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir) - tf = helpers.create_temp_file(suffix=f".{instance.profile.extension}", dir=temp_dir) - with open(seg_file, "w") as ff: - for f in chunks_paths: - ff.write(f"file {f}\n") - cmd = [ - settings.FFMPEG_COMMAND, - "-y", - "-f", - "concat", - "-safe", - "0", - "-i", - seg_file, - "-c", - "copy", - "-pix_fmt", - "yuv420p", - "-movflags", - "faststart", - tf, - ] - stdout = helpers.run_command(cmd) - - encoding = Encoding( - media=instance.media, - profile=instance.profile, - status="success", - progress=100, - ) - all_logs = "\n".join([st.logs for st in chunks]) - encoding.logs = f"{chunks_paths}\n{stdout}\n{all_logs}" - workers = list(set([st.worker for st in chunks])) - encoding.worker = json.dumps({"workers": workers}) - - start_date = min([st.add_date for st in chunks]) - end_date = max([st.update_date for st in chunks]) - encoding.total_run_time = (end_date - start_date).seconds - encoding.save() - - with open(tf, "rb") as f: - myfile = File(f) - output_name = f"{helpers.get_file_name(instance.media.media_file.path)}.{instance.profile.extension}" - encoding.media_file.save(content=myfile, name=output_name) - - # encoding is saved, deleting chunks - # and any other encoding that might exist - # first perform one last validation - # to avoid that this is run twice - if ( - len(orig_chunks) - == Encoding.objects.filter( # noqa - media=instance.media, - profile=instance.profile, - chunks_info=instance.chunks_info, - ).count() - ): - # if two chunks are finished at the same time, this - # will be changed - who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id) - who.delete() - else: - encoding.delete() - if not Encoding.objects.filter(chunks_info=instance.chunks_info): - # TODO: in case of remote workers, files should be deleted - # example - # for worker in workers: - # for chunk in json.loads(instance.chunks_info).keys(): - # remove_media_file.delay(media_file=chunk) - for chunk in json.loads(instance.chunks_info).keys(): - helpers.rm_file(chunk) - instance.media.post_encode_actions(encoding=instance, action="add") - - elif instance.chunk and instance.status == "fail": - encoding = Encoding(media=instance.media, profile=instance.profile, status="fail", progress=100) - - chunks = Encoding.objects.filter(media=instance.media, chunks_info=instance.chunks_info, chunk=True).order_by("add_date") - - chunks_paths = [f.media_file.path for f in chunks] - - all_logs = "\n".join([st.logs for st in chunks]) - encoding.logs = f"{chunks_paths}\n{all_logs}" - workers = list(set([st.worker for st in chunks])) - encoding.worker = json.dumps({"workers": workers}) - start_date = min([st.add_date for st in chunks]) - end_date = max([st.update_date for st in chunks]) - encoding.total_run_time = (end_date - start_date).seconds - encoding.save() - - who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id) - - who.delete() - # TODO: merge with above if, do not repeat code - else: - if instance.status in ["fail", "success"]: - instance.media.post_encode_actions(encoding=instance, action="add") - - encodings = set([encoding.status for encoding in Encoding.objects.filter(media=instance.media)]) - if ("running" in encodings) or ("pending" in encodings): - return - - -@receiver(post_delete, sender=Encoding) -def encoding_file_delete(sender, instance, **kwargs): - """ - Deletes file from filesystem - when corresponding `Encoding` object is deleted. - """ - - if instance.media_file: - helpers.rm_file(instance.media_file.path) - if not instance.chunk: - instance.media.post_encode_actions(encoding=instance, action="delete") - # delete local chunks, and remote chunks + media file. Only when the - # last encoding of a media is complete diff --git a/files/models/playlist.py b/files/models/playlist.py new file mode 100644 index 00000000..cd747713 --- /dev/null +++ b/files/models/playlist.py @@ -0,0 +1,97 @@ +import uuid + +from django.db import models +from django.urls import reverse +from django.utils.html import strip_tags + +from .. import helpers + + +class Playlist(models.Model): + """Playlists model""" + + add_date = models.DateTimeField(auto_now_add=True, db_index=True) + + description = models.TextField(blank=True, help_text="description") + + friendly_token = models.CharField(blank=True, max_length=12, db_index=True) + + media = models.ManyToManyField("Media", through="playlistmedia", blank=True) + + title = models.CharField(max_length=100, db_index=True) + + uid = models.UUIDField(unique=True, default=uuid.uuid4) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True, related_name="playlists") + + def __str__(self): + return self.title + + @property + def media_count(self): + return self.media.filter(listable=True).count() + + def get_absolute_url(self, api=False): + if api: + return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token}) + else: + return reverse("get_playlist", kwargs={"friendly_token": self.friendly_token}) + + @property + def url(self): + return self.get_absolute_url() + + @property + def api_url(self): + return self.get_absolute_url(api=True) + + def user_thumbnail_url(self): + if self.user.logo: + return helpers.url_from_path(self.user.logo.path) + return None + + def set_ordering(self, media, ordering): + if media not in self.media.all(): + return False + pm = PlaylistMedia.objects.filter(playlist=self, media=media).first() + if pm and isinstance(ordering, int) and 0 < ordering: + pm.ordering = ordering + pm.save() + return True + return False + + def save(self, *args, **kwargs): + strip_text_items = ["title", "description"] + for item in strip_text_items: + setattr(self, item, strip_tags(getattr(self, item, None))) + self.title = self.title[:99] + + if not self.friendly_token: + while True: + friendly_token = helpers.produce_friendly_token() + if not Playlist.objects.filter(friendly_token=friendly_token): + self.friendly_token = friendly_token + break + super(Playlist, self).save(*args, **kwargs) + + @property + def thumbnail_url(self): + pm = self.playlistmedia_set.filter(media__listable=True).first() + if pm and pm.media.thumbnail: + return helpers.url_from_path(pm.media.thumbnail.path) + return None + + +class PlaylistMedia(models.Model): + """Helper model to store playlist specific media""" + + action_date = models.DateTimeField(auto_now=True) + + media = models.ForeignKey("Media", on_delete=models.CASCADE) + + playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE) + + ordering = models.IntegerField(default=1) + + class Meta: + ordering = ["ordering", "-action_date"] diff --git a/files/models/rating.py b/files/models/rating.py new file mode 100644 index 00000000..94ea8c2b --- /dev/null +++ b/files/models/rating.py @@ -0,0 +1,47 @@ +from django.db import models + +from .utils import validate_rating + + +class RatingCategory(models.Model): + """Rating Category + Facilitate user ratings. + One or more rating categories per Category can exist + will be shown to the media if they are enabled + """ + + description = models.TextField(blank=True) + + enabled = models.BooleanField(default=True) + + title = models.CharField(max_length=200, unique=True, db_index=True) + + class Meta: + verbose_name_plural = "Rating Categories" + + def __str__(self): + return f"{self.title}" + + +class Rating(models.Model): + """User Rating""" + + add_date = models.DateTimeField(auto_now_add=True) + + media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="ratings") + + rating_category = models.ForeignKey(RatingCategory, on_delete=models.CASCADE) + + score = models.IntegerField(validators=[validate_rating]) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE) + + class Meta: + verbose_name_plural = "Ratings" + indexes = [ + models.Index(fields=["user", "media"]), + ] + unique_together = ("user", "media", "rating_category") + + def __str__(self): + return f"{self.user.username}, rate for {self.media.title} for category {self.rating_category.title}" diff --git a/files/models/subtitle.py b/files/models/subtitle.py new file mode 100644 index 00000000..e0e34d35 --- /dev/null +++ b/files/models/subtitle.py @@ -0,0 +1,72 @@ +import os +import tempfile + +from django.conf import settings +from django.db import models +from django.urls import reverse + +from .. import helpers +from .utils import subtitles_file_path + + +class Language(models.Model): + """Language model + to be used with Subtitles + """ + + code = models.CharField(max_length=12, help_text="language code") + + title = models.CharField(max_length=100, help_text="language code") + + class Meta: + ordering = ["id"] + + def __str__(self): + return f"{self.code}-{self.title}" + + +class Subtitle(models.Model): + """Subtitles model""" + + language = models.ForeignKey(Language, on_delete=models.CASCADE) + + media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="subtitles") + + subtitle_file = models.FileField( + "Subtitle/CC file", + help_text="File has to be WebVTT format", + upload_to=subtitles_file_path, + max_length=500, + ) + + user = models.ForeignKey("users.User", on_delete=models.CASCADE) + + class Meta: + ordering = ["language__title"] + + def __str__(self): + return f"{self.media.title}-{self.language.title}" + + def get_absolute_url(self): + return f"{reverse('edit_subtitle')}?id={self.id}" + + @property + def url(self): + return self.get_absolute_url() + + def convert_to_srt(self): + input_path = self.subtitle_file.path + with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname: + pysub = settings.PYSUBS_COMMAND + + cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname] + stdout = helpers.run_command(cmd) + + list_of_files = os.listdir(tmpdirname) + if list_of_files: + subtitles_file = os.path.join(tmpdirname, list_of_files[0]) + cmd = ["cp", subtitles_file, input_path] + stdout = helpers.run_command(cmd) # noqa + else: + raise Exception("Could not convert to srt") + return True diff --git a/files/models/utils.py b/files/models/utils.py new file mode 100644 index 00000000..d999533a --- /dev/null +++ b/files/models/utils.py @@ -0,0 +1,99 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.crypto import get_random_string + +from .. import helpers + +# this is used by Media and Encoding models +# reflects media encoding status for objects +MEDIA_ENCODING_STATUS = ( + ("pending", "Pending"), + ("running", "Running"), + ("fail", "Fail"), + ("success", "Success"), +) + +# the media state of a Media object +# this is set by default according to the portal workflow +MEDIA_STATES = ( + ("private", "Private"), + ("public", "Public"), + ("unlisted", "Unlisted"), +) + +# each uploaded Media gets a media_type hint +# by helpers.get_file_type + +MEDIA_TYPES_SUPPORTED = ( + ("video", "Video"), + ("image", "Image"), + ("pdf", "Pdf"), + ("audio", "Audio"), +) + +ENCODE_EXTENSIONS = ( + ("mp4", "mp4"), + ("webm", "webm"), + ("gif", "gif"), +) + +ENCODE_RESOLUTIONS = ( + (2160, "2160"), + (1440, "1440"), + (1080, "1080"), + (720, "720"), + (480, "480"), + (360, "360"), + (240, "240"), + (144, "144"), +) + +CODECS = ( + ("h265", "h265"), + ("h264", "h264"), + ("vp9", "vp9"), +) + +ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS] +ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS] + + +def generate_uid(): + return get_random_string(length=16) + + +def original_media_file_path(instance, filename): + """Helper function to place original media file""" + file_name = f"{instance.uid.hex}.{helpers.get_file_name(filename)}" + return settings.MEDIA_UPLOAD_DIR + f"user/{instance.user.username}/{file_name}" + + +def encoding_media_file_path(instance, filename): + """Helper function to place encoded media file""" + + file_name = f"{instance.media.uid.hex}.{helpers.get_file_name(filename)}" + return settings.MEDIA_ENCODING_DIR + f"{instance.profile.id}/{instance.media.user.username}/{file_name}" + + +def original_thumbnail_file_path(instance, filename): + """Helper function to place original media thumbnail file""" + + return settings.THUMBNAIL_UPLOAD_DIR + f"user/{instance.user.username}/{filename}" + + +def subtitles_file_path(instance, filename): + """Helper function to place subtitle file""" + + return settings.SUBTITLES_UPLOAD_DIR + f"user/{instance.media.user.username}/{filename}" + + +def category_thumb_path(instance, filename): + """Helper function to place category thumbnail file""" + + file_name = f"{instance.uid}.{helpers.get_file_name(filename)}" + return settings.MEDIA_UPLOAD_DIR + f"categories/{file_name}" + + +def validate_rating(value): + if -1 >= value or value > 5: + raise ValidationError("score has to be between 0 and 5") diff --git a/files/models/video_data.py b/files/models/video_data.py new file mode 100644 index 00000000..489244f6 --- /dev/null +++ b/files/models/video_data.py @@ -0,0 +1,86 @@ +from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from .. import helpers + + +class VideoChapterData(models.Model): + data = models.JSONField(null=False, blank=False, help_text="Chapter data") + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters') + + class Meta: + unique_together = ['media'] + + def save(self, *args, **kwargs): + from .. import tasks + + is_new = self.pk is None + if is_new or (not is_new and self._check_data_changed()): + super().save(*args, **kwargs) + tasks.produce_video_chapters.delay(self.pk) + else: + super().save(*args, **kwargs) + + def _check_data_changed(self): + if self.pk: + old_instance = VideoChapterData.objects.get(pk=self.pk) + return old_instance.data != self.data + return False + + @property + def chapter_data(self): + # ensure response is consistent + data = [] + for item in self.data: + if item.get("start") and item.get("title"): + thumbnail = item.get("thumbnail") + if thumbnail: + thumbnail = helpers.url_from_path(thumbnail) + else: + thumbnail = "static/images/chapter_default.jpg" + data.append( + { + "start": item.get("start"), + "title": item.get("title"), + "thumbnail": thumbnail, + } + ) + return data + + +class VideoTrimRequest(models.Model): + """Model to handle video trimming requests""" + + VIDEO_TRIM_STATUS = ( + ("initial", "Initial"), + ("running", "Running"), + ("success", "Success"), + ("fail", "Fail"), + ) + + VIDEO_ACTION_CHOICES = ( + ("replace", "Replace Original"), + ("save_new", "Save as New"), + ("create_segments", "Create Segments"), + ) + + TRIM_STYLE_CHOICES = ( + ("no_encoding", "No Encoding"), + ("precise", "Precise"), + ) + + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests') + status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial") + add_date = models.DateTimeField(auto_now_add=True) + video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES) + media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding") + timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming") + + def __str__(self): + return f"Trim request for {self.media.title} ({self.status})" + + +@receiver(post_delete, sender=VideoChapterData) +def videochapterdata_delete(sender, instance, **kwargs): + helpers.rm_dir(instance.media.video_chapters_folder) diff --git a/files/tasks.py b/files/tasks.py index 037128c2..63ac986f 100644 --- a/files/tasks.py +++ b/files/tasks.py @@ -820,7 +820,7 @@ def update_listings_thumbnails(): # Categories used_media = [] saved = 0 - qs = Category.objects.filter().order_by("-media_count") + qs = Category.objects.filter() for object in qs: media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first() if media: @@ -833,7 +833,7 @@ def update_listings_thumbnails(): # Tags used_media = [] saved = 0 - qs = Tag.objects.filter().order_by("-media_count") + qs = Tag.objects.filter() for object in qs: media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first() if media: diff --git a/files/urls.py b/files/urls.py index 7e590064..a9570a3c 100644 --- a/files/urls.py +++ b/files/urls.py @@ -48,6 +48,8 @@ urlpatterns = [ re_path(r"^view", views.view_media, name="get_media"), re_path(r"^upload", views.upload_media, name="upload_media"), # API VIEWS + re_path(r"^api/v1/media/user/bulk_actions$", views.MediaBulkUserActions.as_view()), + re_path(r"^api/v1/media/user/bulk_actions/$", views.MediaBulkUserActions.as_view()), re_path(r"^api/v1/media$", views.MediaList.as_view()), re_path(r"^api/v1/media/$", views.MediaList.as_view()), re_path( diff --git a/files/views.py b/files/views.py deleted file mode 100644 index 433f09cc..00000000 --- a/files/views.py +++ /dev/null @@ -1,1744 +0,0 @@ -import json -from datetime import datetime, timedelta - -from allauth.socialaccount.models import SocialApp -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.contrib.postgres.search import SearchQuery -from django.core.mail import EmailMessage -from django.db.models import Q -from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views.decorators.csrf import csrf_exempt -from drf_yasg import openapi as openapi -from drf_yasg.utils import swagger_auto_schema -from rest_framework import permissions, status -from rest_framework.exceptions import PermissionDenied -from rest_framework.parsers import ( - FileUploadParser, - FormParser, - JSONParser, - MultiPartParser, -) -from rest_framework.response import Response -from rest_framework.settings import api_settings -from rest_framework.views import APIView - -from actions.models import USER_MEDIA_ACTIONS, MediaAction -from cms.custom_pagination import FastPaginationWithoutCount -from cms.permissions import ( - IsAuthorizedToAdd, - IsAuthorizedToAddComment, - IsUserOrEditor, - user_allowed_to_upload, -) -from cms.version import VERSION -from identity_providers.models import LoginOption -from users.models import User - -from . import helpers -from .forms import ( - ContactForm, - EditSubtitleForm, - MediaMetadataForm, - MediaPublishForm, - SubtitleForm, -) -from .frontend_translations import translate_string -from .helpers import clean_query, get_alphanumeric_only, produce_ffmpeg_commands -from .methods import ( - check_comment_for_mention, - create_video_trim_request, - get_user_or_session, - handle_video_chapters, - is_mediacms_editor, - list_tasks, - notify_user_on_comment, - show_recommended_media, - show_related_media, - update_user_ratings, -) -from .models import ( - Category, - Comment, - EncodeProfile, - Encoding, - Media, - Playlist, - PlaylistMedia, - Subtitle, - Tag, - VideoTrimRequest, -) -from .serializers import ( - CategorySerializer, - CommentSerializer, - EncodeProfileSerializer, - MediaSearchSerializer, - MediaSerializer, - PlaylistDetailSerializer, - PlaylistSerializer, - SingleMediaSerializer, - TagSerializer, -) -from .stop_words import STOP_WORDS -from .tasks import save_user_action, video_trim_task - -VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS] - - -def about(request): - """About view""" - - context = {"VERSION": VERSION} - return render(request, "cms/about.html", context) - - -def setlanguage(request): - """Set Language view""" - - context = {} - return render(request, "cms/set_language.html", context) - - -@login_required -def add_subtitle(request): - """Add subtitle view""" - - 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 == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - if request.method == "POST": - form = SubtitleForm(media, request.POST, request.FILES) - if form.is_valid(): - subtitle = form.save() - new_subtitle = Subtitle.objects.filter(id=subtitle.id).first() - try: - new_subtitle.convert_to_srt() - messages.add_message(request, messages.INFO, "Subtitle was added!") - return HttpResponseRedirect(subtitle.media.get_absolute_url()) - except: # noqa: E722 - new_subtitle.delete() - error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files." - form.add_error("subtitle_file", error_msg) - - else: - form = SubtitleForm(media_item=media) - subtitles = media.subtitles.all() - context = {"media": media, "form": form, "subtitles": subtitles} - return render(request, "cms/add_subtitle.html", context) - - -@login_required -def edit_subtitle(request): - subtitle_id = request.GET.get("id", "").strip() - action = request.GET.get("action", "").strip() - if not subtitle_id: - return HttpResponseRedirect("/") - subtitle = Subtitle.objects.filter(id=subtitle_id).first() - - if not subtitle: - return HttpResponseRedirect("/") - - if not (request.user == subtitle.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - context = {"subtitle": subtitle, "action": action} - - if action == "download": - response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt") - filename = subtitle.subtitle_file.name.split("/")[-1] - - if not filename.endswith(".vtt"): - filename = f"{filename}.vtt" - - response["Content-Disposition"] = f"attachment; filename={filename}" # noqa - - return response - - if request.method == "GET": - form = EditSubtitleForm(subtitle) - context["form"] = form - elif request.method == "POST": - confirm = request.GET.get("confirm", "").strip() - if confirm == "true": - messages.add_message(request, messages.INFO, "Subtitle was deleted") - redirect_url = subtitle.media.get_absolute_url() - subtitle.delete() - return HttpResponseRedirect(redirect_url) - form = EditSubtitleForm(subtitle, request.POST) - subtitle_text = form.data["subtitle"] - with open(subtitle.subtitle_file.path, "w") as ff: - ff.write(subtitle_text) - - messages.add_message(request, messages.INFO, "Subtitle was edited") - return HttpResponseRedirect(subtitle.media.get_absolute_url()) - return render(request, "cms/edit_subtitle.html", context) - - -def categories(request): - """List categories view""" - - context = {} - return render(request, "cms/categories.html", context) - - -def contact(request): - """Contact view""" - - context = {} - if request.method == "GET": - form = ContactForm(request.user) - context["form"] = form - - else: - form = ContactForm(request.user, request.POST) - if form.is_valid(): - if request.user.is_authenticated: - from_email = request.user.email - name = request.user.name - else: - from_email = request.POST.get("from_email") - name = request.POST.get("name") - message = request.POST.get("message") - - title = f"[{settings.PORTAL_NAME}] - Contact form message received" - - msg = """ -You have received a message through the contact form\n -Sender name: %s -Sender email: %s\n -\n %s -""" % ( - name, - from_email, - message, - ) - email = EmailMessage( - title, - msg, - settings.DEFAULT_FROM_EMAIL, - settings.ADMIN_EMAIL_LIST, - reply_to=[from_email], - ) - email.send(fail_silently=True) - success_msg = "Message was sent! Thanks for contacting" - context["success_msg"] = success_msg - - return render(request, "cms/contact.html", context) - - -def history(request): - """Show personal history view""" - - context = {} - return render(request, "cms/history.html", context) - - -@csrf_exempt -@login_required -def video_chapters(request, friendly_token): - # this is not ready... - return False - if not request.method == "POST": - return HttpResponseRedirect("/") - - media = Media.objects.filter(friendly_token=friendly_token).first() - - if not media: - return HttpResponseRedirect("/") - - if not (request.user == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - try: - data = json.loads(request.body)["chapters"] - chapters = [] - for _, chapter_data in enumerate(data): - start_time = chapter_data.get('start') - title = chapter_data.get('title') - if start_time and title: - chapters.append( - { - 'start': start_time, - 'title': title, - } - ) - except Exception as e: # noqa - return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400) - - ret = handle_video_chapters(media, chapters) - - return JsonResponse(ret, safe=False) - - -@login_required -def edit_media(request): - """Edit a media view""" - - 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 == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - if request.method == "POST": - form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media) - if form.is_valid(): - media = form.save() - for tag in media.tags.all(): - media.tags.remove(tag) - if form.cleaned_data.get("new_tags"): - for tag in form.cleaned_data.get("new_tags").split(","): - tag = get_alphanumeric_only(tag) - tag = tag[:99] - if tag: - try: - tag = Tag.objects.get(title=tag) - except Tag.DoesNotExist: - tag = Tag.objects.create(title=tag, user=request.user) - if tag not in media.tags.all(): - media.tags.add(tag) - messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) - return HttpResponseRedirect(media.get_absolute_url()) - else: - form = MediaMetadataForm(request.user, instance=media) - return render( - request, - "cms/edit_media.html", - {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, - ) - - -@login_required -def publish_media(request): - """Publish media""" - - 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 == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - if request.method == "POST": - form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media) - if form.is_valid(): - media = form.save() - messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) - return HttpResponseRedirect(media.get_absolute_url()) - else: - form = MediaPublishForm(request.user, instance=media) - - return render( - request, - "cms/publish_media.html", - {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, - ) - - -@login_required -def edit_chapters(request): - """Edit chapters""" - # not implemented yet - return False - 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 == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - return render( - request, - "cms/edit_chapters.html", - {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token}, - ) - - -@csrf_exempt -@login_required -def trim_video(request, friendly_token): - if not settings.ALLOW_VIDEO_TRIMMER: - return JsonResponse({"success": False, "error": "Video trimming is not allowed"}, status=400) - - if not request.method == "POST": - return HttpResponseRedirect("/") - - media = Media.objects.filter(friendly_token=friendly_token).first() - - if not media: - return HttpResponseRedirect("/") - - if not (request.user == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() - - if existing_requests: - return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400) - - try: - data = json.loads(request.body) - video_trim_request = create_video_trim_request(media, data) - video_trim_task.delay(video_trim_request.id) - ret = {"success": True, "request_id": video_trim_request.id} - return JsonResponse(ret, safe=False, status=200) - except Exception as e: # noqa - ret = {"success": False, "error": "Incorrect request data"} - return JsonResponse(ret, safe=False, status=400) - - -@login_required -def edit_video(request): - """Edit video""" - - 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 == media.user or is_mediacms_editor(request.user)): - return HttpResponseRedirect("/") - - if not media.media_type == "video": - messages.add_message(request, messages.INFO, "Media is not video") - return HttpResponseRedirect(media.get_absolute_url()) - - if not settings.ALLOW_VIDEO_TRIMMER: - messages.add_message(request, messages.INFO, "Video Trimmer is not enabled") - return HttpResponseRedirect(media.get_absolute_url()) - - # Check if there's a running trim request - running_trim_request = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() - - if running_trim_request: - messages.add_message(request, messages.INFO, "Video trim request is already running") - return HttpResponseRedirect(media.get_absolute_url()) - - media_file_path = media.trim_video_url - - if not media_file_path: - messages.add_message(request, messages.INFO, "Media processing has not finished yet") - return HttpResponseRedirect(media.get_absolute_url()) - - if media.encoding_status in ["pending", "running"]: - video_msg = "Media encoding hasn't finished yet. Attempting to show the original video file" - messages.add_message(request, messages.INFO, video_msg) - - return render( - request, - "cms/edit_video.html", - {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path}, - ) - - -def embed_media(request): - """Embed media view""" - - friendly_token = request.GET.get("m", "").strip() - if not friendly_token: - return HttpResponseRedirect("/") - - media = Media.objects.values("title").filter(friendly_token=friendly_token).first() - - if not media: - return HttpResponseRedirect("/") - - context = {} - context["media"] = friendly_token - return render(request, "cms/embed.html", context) - - -def featured_media(request): - """List featured media view""" - - context = {} - return render(request, "cms/featured-media.html", context) - - -def index(request): - """Index view""" - - context = {} - return render(request, "cms/index.html", context) - - -def latest_media(request): - """List latest media view""" - - context = {} - return render(request, "cms/latest-media.html", context) - - -def liked_media(request): - """List user's liked media view""" - - context = {} - return render(request, "cms/liked_media.html", context) - - -@login_required -def manage_users(request): - """List users management view""" - - if not is_mediacms_editor(request.user): - return HttpResponseRedirect("/") - - context = {} - return render(request, "cms/manage_users.html", context) - - -@login_required -def manage_media(request): - """List media management view""" - if not is_mediacms_editor(request.user): - return HttpResponseRedirect("/") - - categories = Category.objects.all().order_by('title').values_list('title', flat=True) - context = {'categories': list(categories)} - return render(request, "cms/manage_media.html", context) - - -@login_required -def manage_comments(request): - """List comments management view""" - if not is_mediacms_editor(request.user): - return HttpResponseRedirect("/") - - context = {} - return render(request, "cms/manage_comments.html", context) - - -def members(request): - """List members view""" - - context = {} - return render(request, "cms/members.html", context) - - -def recommended_media(request): - """List recommended media view""" - - context = {} - return render(request, "cms/recommended-media.html", context) - - -def search(request): - """Search view""" - - context = {} - RSS_URL = f"/rss{request.environ.get('REQUEST_URI')}" - context["RSS_URL"] = RSS_URL - return render(request, "cms/search.html", context) - - -def sitemap(request): - """Sitemap""" - - context = {} - context["media"] = list(Media.objects.filter(Q(listable=True)).order_by("-add_date")) - context["playlists"] = list(Playlist.objects.filter().order_by("-add_date")) - context["users"] = list(User.objects.filter()) - return render(request, "sitemap.xml", context, content_type="application/xml") - - -def tags(request): - """List tags view""" - - context = {} - return render(request, "cms/tags.html", context) - - -def tos(request): - """Terms of service view""" - - context = {} - return render(request, "cms/tos.html", context) - - -@login_required -def upload_media(request): - """Upload media view""" - - from allauth.account.forms import LoginForm - - form = LoginForm() - context = {} - context["form"] = form - context["can_add"] = user_allowed_to_upload(request) - can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE - context["can_upload_exp"] = can_upload_exp - - return render(request, "cms/add-media.html", context) - - -def view_media(request): - """View media view""" - - friendly_token = request.GET.get("m", "").strip() - context = {} - media = Media.objects.filter(friendly_token=friendly_token).first() - if not media: - context["media"] = None - return render(request, "cms/media.html", context) - - user_or_session = get_user_or_session(request) - save_user_action.delay(user_or_session, friendly_token=friendly_token, action="watch") - context = {} - context["media"] = friendly_token - context["media_object"] = media - - context["CAN_DELETE_MEDIA"] = False - context["CAN_EDIT_MEDIA"] = False - context["CAN_DELETE_COMMENTS"] = False - - if request.user.is_authenticated: - if media.user.id == request.user.id or is_mediacms_editor(request.user): - context["CAN_DELETE_MEDIA"] = True - context["CAN_EDIT_MEDIA"] = True - context["CAN_DELETE_COMMENTS"] = 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': - video_msg = None - if media.encoding_status == "pending": - video_msg = "Media encoding hasn't started yet. Attempting to show the original video file" - if media.encoding_status == "running": - video_msg = "Media encoding is under processing. Attempting to show the original video file" - if video_msg: - messages.add_message(request, messages.INFO, video_msg) - - return render(request, "cms/media.html", context) - - -def view_playlist(request, friendly_token): - """View playlist view""" - - try: - playlist = Playlist.objects.get(friendly_token=friendly_token) - except BaseException: - playlist = None - - context = {} - context["playlist"] = playlist - return render(request, "cms/playlist.html", context) - - -class MediaList(APIView): - """Media listings views""" - - permission_classes = (IsAuthorizedToAdd,) - parser_classes = (MultiPartParser, FormParser, FileUploadParser) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), - openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'), - openapi.Parameter(name='show', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='show', enum=['recommended', 'featured', 'latest']), - ], - tags=['Media'], - operation_summary='List Media', - operation_description='Lists all media', - responses={200: MediaSerializer(many=True)}, - ) - def get(self, request, format=None): - # Show media - params = self.request.query_params - show_param = params.get("show", "") - - author_param = params.get("author", "").strip() - if author_param: - user_queryset = User.objects.all() - user = get_object_or_404(user_queryset, username=author_param) - if show_param == "recommended": - pagination_class = FastPaginationWithoutCount - media = show_recommended_media(request, limit=50) - else: - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - if author_param: - # in case request.user is the user here, show - # all media independant of state - if self.request.user == user: - basic_query = Q(user=user) - else: - basic_query = Q(listable=True, user=user) - else: - # base listings should show safe content - basic_query = Q(listable=True) - - if show_param == "featured": - media = Media.objects.filter(basic_query, featured=True) - else: - media = Media.objects.filter(basic_query).order_by("-add_date") - - paginator = pagination_class() - - if show_param != "recommended": - media = media.prefetch_related("user") - page = paginator.paginate_queryset(media, request) - - serializer = MediaSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=True, description="media_file"), - openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"), - openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"), - ], - tags=['Media'], - operation_summary='Add new Media', - operation_description='Adds a new media, for authenticated users', - responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'}, - ) - def post(self, request, format=None): - # Add new media - serializer = MediaSerializer(data=request.data, context={"request": request}) - if serializer.is_valid(): - media_file = request.data["media_file"] - serializer.save(user=request.user, media_file=media_file) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class MediaDetail(APIView): - """ - Retrieve, update or delete a media instance. - """ - - permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor) - parser_classes = (MultiPartParser, FormParser, FileUploadParser) - - def get_object(self, friendly_token, password=None): - try: - media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token) - - # this need be explicitly called, and will call - # has_object_permission() after has_permission has succeeded - self.check_object_permissions(self.request, media) - if media.state == "private" and not (self.request.user == media.user or is_mediacms_editor(self.request.user)): - if getattr(settings, 'USE_RBAC', False) and self.request.user.is_authenticated and self.request.user.has_member_access_to_media(media): - pass - elif (not password) or (not media.password) or (password != media.password): - return Response( - {"detail": "media is private"}, - status=status.HTTP_401_UNAUTHORIZED, - ) - return media - except PermissionDenied: - return Response({"detail": "bad permissions"}, status=status.HTTP_401_UNAUTHORIZED) - except BaseException: - return Response( - {"detail": "media file does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), - ], - tags=['Media'], - operation_summary='Get information for Media', - operation_description='Get information for a media', - responses={200: SingleMediaSerializer(), 400: 'bad request'}, - ) - def get(self, request, friendly_token, format=None): - # Get media details - password = request.GET.get("password") - media = self.get_object(friendly_token, password=password) - if isinstance(media, Response): - return media - - serializer = SingleMediaSerializer(media, context={"request": request}) - if media.state == "private": - related_media = [] - else: - related_media = show_related_media(media, request=request, limit=100) - related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request}) - related_media = related_media_serializer.data - ret = serializer.data - - # update rattings info with user specific ratings - # eg user has already rated for this media - # this only affects user rating and only if enabled - if settings.ALLOW_RATINGS and ret.get("ratings_info") and not request.user.is_anonymous: - ret["ratings_info"] = update_user_ratings(request.user, media, ret.get("ratings_info")) - - ret["related_media"] = related_media - return Response(ret) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), - openapi.Parameter(name='type', type=openapi.TYPE_STRING, in_=openapi.IN_FORM, description='action to perform', enum=['encode', 'review']), - openapi.Parameter( - name='encoding_profiles', - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_STRING), - in_=openapi.IN_FORM, - description='if action to perform is encode, need to specify list of ids of encoding profiles', - ), - openapi.Parameter(name='result', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_FORM, description='if action is review, this is the result (True for reviewed, False for not reviewed)'), - ], - tags=['Media'], - operation_summary='Run action on Media', - operation_description='Actions for a media, for MediaCMS editors and managers', - responses={201: 'action created', 400: 'bad request'}, - operation_id='media_manager_actions', - ) - def post(self, request, friendly_token, format=None): - """superuser actions - Available only to MediaCMS editors and managers - - Action is a POST variable, review and encode are implemented - """ - - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - - if not is_mediacms_editor(request.user): - return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) - - action = request.data.get("type") - profiles_list = request.data.get("encoding_profiles") - result = request.data.get("result", True) - if action == "encode": - # Create encoding tasks for specific profiles - valid_profiles = [] - if profiles_list: - if isinstance(profiles_list, list): - for p in profiles_list: - p = EncodeProfile.objects.filter(id=p).first() - if p: - valid_profiles.append(p) - elif isinstance(profiles_list, str): - try: - p = EncodeProfile.objects.filter(id=int(profiles_list)).first() - valid_profiles.append(p) - except ValueError: - return Response( - {"detail": "encoding_profiles must be int or list of ints of valid encode profiles"}, - status=status.HTTP_400_BAD_REQUEST, - ) - media.encode(profiles=valid_profiles) - return Response({"detail": "media will be encoded"}, status=status.HTTP_201_CREATED) - elif action == "review": - if result: - media.is_reviewed = True - elif result is False: - media.is_reviewed = False - media.save(update_fields=["is_reviewed"]) - return Response({"detail": "media reviewed set"}, status=status.HTTP_201_CREATED) - return Response( - {"detail": "not valid action or no action specified"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"), - openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"), - openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=False, description="media_file"), - ], - tags=['Media'], - operation_summary='Update Media', - operation_description='Update a Media, for Media uploader', - responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'}, - ) - def put(self, request, friendly_token, format=None): - # Update a media object - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - 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) - # else: - # serializer.save(user=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), - ], - tags=['Media'], - operation_summary='Delete Media', - operation_description='Delete a Media, for MediaCMS editors and managers', - responses={ - 204: 'no content', - }, - ) - def delete(self, request, friendly_token, format=None): - # Delete a media object - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - media.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class MediaActions(APIView): - """ - Retrieve, update or delete a media action instance. - """ - - permission_classes = (permissions.AllowAny,) - parser_classes = (JSONParser,) - - def get_object(self, friendly_token): - try: - media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token) - if media.state == "private" and self.request.user != media.user: - return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST) - return media - except PermissionDenied: - return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) - except BaseException: - return Response( - {"detail": "media file does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def get(self, request, friendly_token, format=None): - # show date and reason for each time media was reported - media = self.get_object(friendly_token) - if not (request.user == media.user or is_mediacms_editor(request.user)): - return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) - - if isinstance(media, Response): - return media - - ret = {} - reported = MediaAction.objects.filter(media=media, action="report") - ret["reported"] = [] - for rep in reported: - item = {"reported_date": rep.action_date, "reason": rep.extra_info} - ret["reported"].append(item) - - return Response(ret, status=status.HTTP_200_OK) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def post(self, request, friendly_token, format=None): - # perform like/dislike/report actions - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - - action = request.data.get("type") - extra = request.data.get("extra_info") - if request.user.is_anonymous: - # there is a list of allowed actions for - # anonymous users, specified in settings - if action not in settings.ALLOW_ANONYMOUS_ACTIONS: - return Response( - {"detail": "action allowed on logged in users only"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if action: - user_or_session = get_user_or_session(request) - save_user_action.delay( - user_or_session, - friendly_token=media.friendly_token, - action=action, - extra_info=extra, - ) - - return Response({"detail": "action received"}, status=status.HTTP_201_CREATED) - else: - return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def delete(self, request, friendly_token, format=None): - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - - if not request.user.is_superuser: - return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) - - action = request.data.get("type") - if action: - if action == "report": # delete reported actions - MediaAction.objects.filter(media=media, action="report").delete() - media.reported_times = 0 - media.save(update_fields=["reported_times"]) - return Response( - {"detail": "reset reported times counter"}, - status=status.HTTP_201_CREATED, - ) - else: - return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST) - - -class MediaSearch(APIView): - """ - Retrieve results for search - Only GET is implemented here - """ - - parser_classes = (JSONParser,) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Search'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def get(self, request, format=None): - params = self.request.query_params - query = params.get("q", "").strip().lower() - category = params.get("c", "").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() - - 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 = "-" - - if media_type not in ["video", "image", "audio", "pdf"]: - media_type = None - - if not (query or category or tag): - ret = {} - return Response(ret, status=status.HTTP_200_OK) - - media = Media.objects.filter(state="public", is_reviewed=True) - - if query: - # move this processing to a prepare_query function - query = 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 category: - media = media.filter(category__title__contains=category) - if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated: - c_object = Category.objects.filter(title=category, is_rbac_category=True).first() - if c_object and request.user.has_member_access_to_category(c_object): - # show all media where user has access based on RBAC - media = Media.objects.filter(category=c_object) - - if media_type: - media = media.filter(media_type=media_type) - - if author: - media = media.filter(user__username=author) - - if upload_date: - gte = None - 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) - if gte: - media = media.filter(add_date__gte=gte) - - media = media.order_by(f"{ordering}{sort_by}") - - if self.request.query_params.get("show", "").strip() == "titles": - media = media.values("title")[:40] - return Response(media, status=status.HTTP_200_OK) - else: - media = media.prefetch_related("user") - if category or tag: - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - else: - # pagination_class = FastPaginationWithoutCount - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - page = paginator.paginate_queryset(media, request) - serializer = MediaSearchSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - -class PlaylistList(APIView): - """Playlists listings and creation views""" - - permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd) - parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - responses={ - 200: openapi.Response('response description', PlaylistSerializer(many=True)), - }, - ) - def get(self, request, format=None): - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - playlists = Playlist.objects.filter().prefetch_related("user") - - if "author" in self.request.query_params: - author = self.request.query_params["author"].strip() - playlists = playlists.filter(user__username=author) - - page = paginator.paginate_queryset(playlists, request) - - serializer = PlaylistSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def post(self, request, format=None): - serializer = PlaylistSerializer(data=request.data, context={"request": request}) - if serializer.is_valid(): - serializer.save(user=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class PlaylistDetail(APIView): - """Playlist related views""" - - permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor) - parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) - - def get_playlist(self, friendly_token): - try: - playlist = Playlist.objects.get(friendly_token=friendly_token) - self.check_object_permissions(self.request, playlist) - return playlist - except PermissionDenied: - return Response({"detail": "not enough permissions"}, status=status.HTTP_400_BAD_REQUEST) - except BaseException: - return Response( - {"detail": "Playlist does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def get(self, request, friendly_token, format=None): - playlist = self.get_playlist(friendly_token) - if isinstance(playlist, Response): - return playlist - - serializer = PlaylistDetailSerializer(playlist, context={"request": request}) - - playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user") - - playlist_media = [c.media for c in playlist_media] - - playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request}) - ret = serializer.data - ret["playlist_media"] = playlist_media_serializer.data - - return Response(ret) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def post(self, request, friendly_token, format=None): - playlist = self.get_playlist(friendly_token) - if isinstance(playlist, Response): - return playlist - serializer = PlaylistDetailSerializer(playlist, data=request.data, context={"request": request}) - if serializer.is_valid(): - serializer.save(user=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def put(self, request, friendly_token, format=None): - playlist = self.get_playlist(friendly_token) - if isinstance(playlist, Response): - return playlist - action = request.data.get("type") - media_friendly_token = request.data.get("media_friendly_token") - ordering = 0 - if request.data.get("ordering"): - try: - ordering = int(request.data.get("ordering")) - except ValueError: - pass - - if action in ["add", "remove", "ordering"]: - media = Media.objects.filter(friendly_token=media_friendly_token).first() - if media: - if action == "add": - media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count() - if media_in_playlist >= settings.MAX_MEDIA_PER_PLAYLIST: - return Response( - {"detail": "max number of media for a Playlist reached"}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - obj, created = PlaylistMedia.objects.get_or_create( - playlist=playlist, - media=media, - ordering=media_in_playlist + 1, - ) - obj.save() - return Response( - {"detail": "media added to Playlist"}, - status=status.HTTP_201_CREATED, - ) - elif action == "remove": - PlaylistMedia.objects.filter(playlist=playlist, media=media).delete() - return Response( - {"detail": "media removed from Playlist"}, - status=status.HTTP_201_CREATED, - ) - elif action == "ordering": - if ordering: - playlist.set_ordering(media, ordering) - return Response( - {"detail": "new ordering set"}, - status=status.HTTP_201_CREATED, - ) - else: - return Response({"detail": "media is not valid"}, status=status.HTTP_400_BAD_REQUEST) - return Response( - {"detail": "invalid or not specified action"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Playlists'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def delete(self, request, friendly_token, format=None): - playlist = self.get_playlist(friendly_token) - if isinstance(playlist, Response): - return playlist - - playlist.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class EncodingDetail(APIView): - """Experimental. This View is used by remote workers - Needs heavy testing and documentation. - """ - - permission_classes = (permissions.IsAdminUser,) - parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) - - @swagger_auto_schema(auto_schema=None) - def post(self, request, encoding_id): - ret = {} - force = request.data.get("force", False) - task_id = request.data.get("task_id", False) - action = request.data.get("action", "") - chunk = request.data.get("chunk", False) - chunk_file_path = request.data.get("chunk_file_path", "") - - encoding_status = request.data.get("status", "") - progress = request.data.get("progress", "") - commands = request.data.get("commands", "") - logs = request.data.get("logs", "") - retries = request.data.get("retries", "") - worker = request.data.get("worker", "") - temp_file = request.data.get("temp_file", "") - total_run_time = request.data.get("total_run_time", "") - if action == "start": - try: - encoding = Encoding.objects.get(id=encoding_id) - media = encoding.media - profile = encoding.profile - except BaseException: - Encoding.objects.filter(id=encoding_id).delete() - return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) - # TODO: break chunk True/False logic here - if ( - Encoding.objects.filter( - media=media, - profile=profile, - chunk=chunk, - chunk_file_path=chunk_file_path, - ).count() - > 1 # noqa - and force is False # noqa - ): - Encoding.objects.filter(id=encoding_id).delete() - return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) - else: - Encoding.objects.filter( - media=media, - profile=profile, - chunk=chunk, - chunk_file_path=chunk_file_path, - ).exclude(id=encoding.id).delete() - - encoding.status = "running" - if task_id: - encoding.task_id = task_id - - encoding.save() - if chunk: - original_media_path = chunk_file_path - original_media_md5sum = encoding.md5sum - original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url - else: - original_media_path = media.media_file.path - original_media_md5sum = media.md5sum - original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url - - ret["original_media_url"] = original_media_url - ret["original_media_path"] = original_media_path - ret["original_media_md5sum"] = original_media_md5sum - - # generating the commands here, and will replace these with temporary - # files created on the remote server - tf = "TEMP_FILE_REPLACE" - tfpass = "TEMP_FPASS_FILE_REPLACE" - ffmpeg_commands = produce_ffmpeg_commands( - original_media_path, - media.media_info, - resolution=profile.resolution, - codec=profile.codec, - output_filename=tf, - pass_file=tfpass, - chunk=chunk, - ) - if not ffmpeg_commands: - encoding.delete() - return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) - - ret["duration"] = media.duration - ret["ffmpeg_commands"] = ffmpeg_commands - ret["profile_extension"] = profile.extension - return Response(ret, status=status.HTTP_201_CREATED) - elif action == "update_fields": - try: - encoding = Encoding.objects.get(id=encoding_id) - except BaseException: - return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) - to_update = ["size", "update_date"] - if encoding_status: - encoding.status = encoding_status - to_update.append("status") - if progress: - encoding.progress = progress - to_update.append("progress") - if logs: - encoding.logs = logs - to_update.append("logs") - if commands: - encoding.commands = commands - to_update.append("commands") - if task_id: - encoding.task_id = task_id - to_update.append("task_id") - if total_run_time: - encoding.total_run_time = total_run_time - to_update.append("total_run_time") - if worker: - encoding.worker = worker - to_update.append("worker") - if temp_file: - encoding.temp_file = temp_file - to_update.append("temp_file") - - if retries: - encoding.retries = retries - to_update.append("retries") - - try: - encoding.save(update_fields=to_update) - except BaseException: - return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) - return Response({"status": "success"}, status=status.HTTP_201_CREATED) - - @swagger_auto_schema(auto_schema=None) - def put(self, request, encoding_id, format=None): - encoding_file = request.data["file"] - encoding = Encoding.objects.filter(id=encoding_id).first() - if not encoding: - return Response( - {"detail": "encoding does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - encoding.media_file = encoding_file - encoding.save() - return Response({"detail": "ok"}, status=status.HTTP_201_CREATED) - - -class CommentList(APIView): - permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd) - parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), - openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'), - ], - tags=['Comments'], - operation_summary='Lists Comments', - operation_description='Paginated listing of all comments', - responses={ - 200: openapi.Response('response description', CommentSerializer(many=True)), - }, - ) - def get(self, request, format=None): - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - comments = Comment.objects.filter(media__state="public").order_by("-add_date") - comments = comments.prefetch_related("user") - comments = comments.prefetch_related("media") - params = self.request.query_params - if "author" in params: - author_param = params["author"].strip() - user_queryset = User.objects.all() - user = get_object_or_404(user_queryset, username=author_param) - comments = comments.filter(user=user) - - page = paginator.paginate_queryset(comments, request) - - serializer = CommentSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - -class CommentDetail(APIView): - """Comments related views - Listings of comments for a media (GET) - Create comment (POST) - Delete comment (DELETE) - """ - - permission_classes = (IsAuthorizedToAddComment,) - parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) - - def get_object(self, friendly_token): - try: - media = Media.objects.select_related("user").get(friendly_token=friendly_token) - self.check_object_permissions(self.request, media) - if media.state == "private" and self.request.user != media.user: - return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST) - return media - except PermissionDenied: - return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) - except BaseException: - return Response( - {"detail": "media file does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def get(self, request, friendly_token): - # list comments for a media - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - comments = media.comments.filter().prefetch_related("user") - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - page = paginator.paginate_queryset(comments, request) - serializer = CommentSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def delete(self, request, friendly_token, uid=None): - """Delete a comment - Administrators, MediaCMS editors and managers, - media owner, and comment owners, can delete a comment - """ - if uid: - try: - comment = Comment.objects.get(uid=uid) - except BaseException: - return Response( - {"detail": "comment does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if (comment.user == self.request.user) or comment.media.user == self.request.user or is_mediacms_editor(self.request.user): - comment.delete() - else: - return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) - return Response(status=status.HTTP_204_NO_CONTENT) - - @swagger_auto_schema( - manual_parameters=[], - tags=['Media'], - operation_summary='to_be_written', - operation_description='to_be_written', - ) - def post(self, request, friendly_token): - """Create a comment""" - media = self.get_object(friendly_token) - if isinstance(media, Response): - return media - - if not media.enable_comments: - return Response( - {"detail": "comments not allowed here"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = CommentSerializer(data=request.data, context={"request": request}) - if serializer.is_valid(): - serializer.save(user=request.user, media=media) - if request.user != media.user: - notify_user_on_comment(friendly_token=media.friendly_token) - # here forward the comment to check if a user was mentioned - if settings.ALLOW_MENTION_IN_COMMENTS: - check_comment_for_mention(friendly_token=media.friendly_token, comment_text=serializer.data['text']) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class UserActions(APIView): - parser_classes = (JSONParser,) - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='action', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='action', required=True, enum=VALID_USER_ACTIONS), - ], - tags=['Users'], - operation_summary='List user actions', - operation_description='Lists user actions', - ) - def get(self, request, action): - media = [] - if action in VALID_USER_ACTIONS: - if request.user.is_authenticated: - media = Media.objects.select_related("user").filter(mediaactions__user=request.user, mediaactions__action=action).order_by("-mediaactions__action_date") - elif request.session.session_key: - media = ( - Media.objects.select_related("user") - .filter( - mediaactions__session_key=request.session.session_key, - mediaactions__action=action, - ) - .order_by("-mediaactions__action_date") - ) - - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - page = paginator.paginate_queryset(media, request) - serializer = MediaSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - -class CategoryList(APIView): - """List categories""" - - @swagger_auto_schema( - manual_parameters=[], - tags=['Categories'], - operation_summary='Lists Categories', - operation_description='Lists all categories', - responses={ - 200: openapi.Response('response description', CategorySerializer), - }, - ) - def get(self, request, format=None): - if is_mediacms_editor(request.user): - categories = Category.objects.filter() - else: - categories = Category.objects.filter(is_rbac_category=False) - - if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated: - rbac_categories = request.user.get_rbac_categories_as_member() - categories = categories.union(rbac_categories) - - categories = categories.order_by("title") - - serializer = CategorySerializer(categories, many=True, context={"request": request}) - ret = serializer.data - return Response(ret) - - -class TagList(APIView): - """List tags""" - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), - ], - tags=['Tags'], - operation_summary='Lists Tags', - operation_description='Paginated listing of all tags', - responses={ - 200: openapi.Response('response description', TagSerializer), - }, - ) - def get(self, request, format=None): - tags = Tag.objects.filter().order_by("-media_count") - pagination_class = api_settings.DEFAULT_PAGINATION_CLASS - paginator = pagination_class() - page = paginator.paginate_queryset(tags, request) - serializer = TagSerializer(page, many=True, context={"request": request}) - return paginator.get_paginated_response(serializer.data) - - -class EncodeProfileList(APIView): - """List encode profiles""" - - @swagger_auto_schema( - manual_parameters=[], - tags=['Encoding Profiles'], - operation_summary='List Encoding Profiles', - operation_description='Lists all encoding profiles for videos', - responses={200: EncodeProfileSerializer(many=True)}, - ) - def get(self, request, format=None): - profiles = EncodeProfile.objects.all() - serializer = EncodeProfileSerializer(profiles, many=True, context={"request": request}) - return Response(serializer.data) - - -class TasksList(APIView): - """List tasks""" - - swagger_schema = None - - permission_classes = (permissions.IsAdminUser,) - - def get(self, request, format=None): - ret = list_tasks() - return Response(ret) - - -class TaskDetail(APIView): - """Cancel a task""" - - swagger_schema = None - - permission_classes = (permissions.IsAdminUser,) - - def delete(self, request, uid, format=None): - # This is not imported! - # revoke(uid, terminate=True) - return Response(status=status.HTTP_204_NO_CONTENT) - - -def saml_metadata(request): - if not (hasattr(settings, "USE_SAML") and settings.USE_SAML): - raise Http404 - - xml_parts = [''] - saml_social_apps = SocialApp.objects.filter(provider='saml') - entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/" - xml_parts.append(f'') # noqa - xml_parts.append(f' ') # noqa - xml_parts.append(' ') # noqa - - # Add multiple AssertionConsumerService elements with different indices - for index, app in enumerate(saml_social_apps, start=1): - xml_parts.append( - f' ' # noqa - ) - - xml_parts.append(' ') # noqa - xml_parts.append(' ') # noqa - xml_parts.append('') # noqa - metadata_xml = '\n'.join(xml_parts) - return HttpResponse(metadata_xml, content_type='application/xml') - - -def custom_login_view(request): - if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS): - return redirect(reverse('login_system')) - - login_options = [] - for option in LoginOption.objects.filter(active=True): - login_options.append({'url': option.url, 'title': option.title}) - return render(request, 'account/custom_login_selector.html', {'login_options': login_options}) diff --git a/files/views/__init__.py b/files/views/__init__.py new file mode 100644 index 00000000..76007dfe --- /dev/null +++ b/files/views/__init__.py @@ -0,0 +1,43 @@ +# Import all views for backward compatibility +from .auth import custom_login_view, saml_metadata # noqa: F401 +from .categories import CategoryList, TagList # noqa: F401 +from .comments import CommentDetail, CommentList # noqa: F401 +from .encoding import EncodeProfileList, EncodingDetail # noqa: F401 +from .media import MediaActions # noqa: F401 +from .media import MediaBulkUserActions # noqa: F401 +from .media import MediaDetail # noqa: F401 +from .media import MediaList # noqa: F401 +from .media import MediaSearch # noqa: F401 +from .pages import about # noqa: F401 +from .pages import add_subtitle # noqa: F401 +from .pages import categories # noqa: F401 +from .pages import contact # noqa: F401 +from .pages import edit_chapters # noqa: F401 +from .pages import edit_media # noqa: F401 +from .pages import edit_subtitle # noqa: F401 +from .pages import edit_video # noqa: F401 +from .pages import embed_media # noqa: F401 +from .pages import featured_media # noqa: F401 +from .pages import history # noqa: F401 +from .pages import index # noqa: F401 +from .pages import latest_media # noqa: F401 +from .pages import liked_media # noqa: F401 +from .pages import manage_comments # noqa: F401 +from .pages import manage_media # noqa: F401 +from .pages import manage_users # noqa: F401 +from .pages import members # noqa: F401 +from .pages import publish_media # noqa: F401 +from .pages import recommended_media # noqa: F401 +from .pages import search # noqa: F401 +from .pages import setlanguage # noqa: F401 +from .pages import sitemap # noqa: F401 +from .pages import tags # noqa: F401 +from .pages import tos # noqa: F401 +from .pages import trim_video # noqa: F401 +from .pages import upload_media # noqa: F401 +from .pages import video_chapters # noqa: F401 +from .pages import view_media # noqa: F401 +from .pages import view_playlist # noqa: F401 +from .playlists import PlaylistDetail, PlaylistList # noqa: F401 +from .tasks import TaskDetail, TasksList # noqa: F401 +from .user import UserActions # noqa: F401 diff --git a/files/views/auth.py b/files/views/auth.py new file mode 100644 index 00000000..419b86e4 --- /dev/null +++ b/files/views/auth.py @@ -0,0 +1,42 @@ +from allauth.socialaccount.models import SocialApp +from django.conf import settings +from django.http import Http404, HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse + +from identity_providers.models import LoginOption + + +def saml_metadata(request): + if not (hasattr(settings, "USE_SAML") and settings.USE_SAML): + raise Http404 + + xml_parts = [''] + saml_social_apps = SocialApp.objects.filter(provider='saml') + entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/" + xml_parts.append(f'') # noqa + xml_parts.append(f' ') # noqa + xml_parts.append(' ') # noqa + + # Add multiple AssertionConsumerService elements with different indices + for index, app in enumerate(saml_social_apps, start=1): + xml_parts.append( + f' ' # noqa + ) + + xml_parts.append(' ') # noqa + xml_parts.append(' ') # noqa + xml_parts.append('') # noqa + metadata_xml = '\n'.join(xml_parts) + return HttpResponse(metadata_xml, content_type='application/xml') + + +def custom_login_view(request): + if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS): + return redirect(reverse('login_system')) + + login_options = [] + for option in LoginOption.objects.filter(active=True): + login_options.append({'url': option.url, 'title': option.title}) + return render(request, 'account/custom_login_selector.html', {'login_options': login_options}) diff --git a/files/views/categories.py b/files/views/categories.py new file mode 100644 index 00000000..3e101a51 --- /dev/null +++ b/files/views/categories.py @@ -0,0 +1,66 @@ +from django.conf import settings +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from ..methods import is_mediacms_editor +from ..models import Category, Tag +from ..serializers import CategorySerializer, TagSerializer + + +class CategoryList(APIView): + """List categories""" + + @swagger_auto_schema( + manual_parameters=[], + tags=['Categories'], + operation_summary='Lists Categories', + operation_description='Lists all categories', + responses={ + 200: openapi.Response('response description', CategorySerializer), + }, + ) + def get(self, request, format=None): + base_filters = {} + + if not is_mediacms_editor(request.user): + base_filters = {"is_rbac_category": False} + + base_queryset = Category.objects.prefetch_related("user") + categories = base_queryset.filter(**base_filters) + + if not is_mediacms_editor(request.user): + if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated: + rbac_categories = request.user.get_rbac_categories_as_member() + categories = categories.union(rbac_categories) + + categories = categories.order_by("title") + + serializer = CategorySerializer(categories, many=True, context={"request": request}) + ret = serializer.data + return Response(ret) + + +class TagList(APIView): + """List tags""" + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), + ], + tags=['Tags'], + operation_summary='Lists Tags', + operation_description='Paginated listing of all tags', + responses={ + 200: openapi.Response('response description', TagSerializer), + }, + ) + def get(self, request, format=None): + tags = Tag.objects.filter().order_by("-media_count") + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + page = paginator.paginate_queryset(tags, request) + serializer = TagSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) diff --git a/files/views/comments.py b/files/views/comments.py new file mode 100644 index 00000000..fad887f6 --- /dev/null +++ b/files/views/comments.py @@ -0,0 +1,159 @@ +from django.conf import settings +from django.shortcuts import get_object_or_404 +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.parsers import ( + FileUploadParser, + FormParser, + JSONParser, + MultiPartParser, +) +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from cms.permissions import IsAuthorizedToAdd, IsAuthorizedToAddComment +from users.models import User + +from ..methods import ( + check_comment_for_mention, + is_mediacms_editor, + notify_user_on_comment, +) +from ..models import Comment, Media +from ..serializers import CommentSerializer + + +class CommentList(APIView): + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd) + parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), + openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'), + ], + tags=['Comments'], + operation_summary='Lists Comments', + operation_description='Paginated listing of all comments', + responses={ + 200: openapi.Response('response description', CommentSerializer(many=True)), + }, + ) + def get(self, request, format=None): + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + comments = Comment.objects.filter(media__state="public").order_by("-add_date") + comments = comments.prefetch_related("user") + comments = comments.prefetch_related("media") + params = self.request.query_params + if "author" in params: + author_param = params["author"].strip() + user_queryset = User.objects.all() + user = get_object_or_404(user_queryset, username=author_param) + comments = comments.filter(user=user) + + page = paginator.paginate_queryset(comments, request) + + serializer = CommentSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) + + +class CommentDetail(APIView): + """Comments related views + Listings of comments for a media (GET) + Create comment (POST) + Delete comment (DELETE) + """ + + permission_classes = (IsAuthorizedToAddComment,) + parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + + def get_object(self, friendly_token): + try: + media = Media.objects.select_related("user").get(friendly_token=friendly_token) + self.check_object_permissions(self.request, media) + if media.state == "private" and self.request.user != media.user: + return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST) + return media + except PermissionDenied: + return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) + except BaseException: + return Response( + {"detail": "media file does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def get(self, request, friendly_token): + # list comments for a media + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + comments = media.comments.filter().prefetch_related("user") + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + page = paginator.paginate_queryset(comments, request) + serializer = CommentSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def delete(self, request, friendly_token, uid=None): + """Delete a comment + Administrators, MediaCMS editors and managers, + media owner, and comment owners, can delete a comment + """ + if uid: + try: + comment = Comment.objects.get(uid=uid) + except BaseException: + return Response( + {"detail": "comment does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if (comment.user == self.request.user) or comment.media.user == self.request.user or is_mediacms_editor(self.request.user): + comment.delete() + else: + return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def post(self, request, friendly_token): + """Create a comment""" + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + if not media.enable_comments: + return Response( + {"detail": "comments not allowed here"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CommentSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save(user=request.user, media=media) + if request.user != media.user: + notify_user_on_comment(friendly_token=media.friendly_token) + # here forward the comment to check if a user was mentioned + if settings.ALLOW_MENTION_IN_COMMENTS: + check_comment_for_mention(friendly_token=media.friendly_token, comment_text=serializer.data['text']) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/files/views/encoding.py b/files/views/encoding.py new file mode 100644 index 00000000..abea3fb3 --- /dev/null +++ b/files/views/encoding.py @@ -0,0 +1,179 @@ +from django.conf import settings +from drf_yasg.utils import swagger_auto_schema +from rest_framework import permissions, status +from rest_framework.parsers import ( + FileUploadParser, + FormParser, + JSONParser, + MultiPartParser, +) +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..helpers import produce_ffmpeg_commands +from ..models import EncodeProfile, Encoding +from ..serializers import EncodeProfileSerializer + + +class EncodingDetail(APIView): + """Experimental. This View is used by remote workers + Needs heavy testing and documentation. + """ + + permission_classes = (permissions.IsAdminUser,) + parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + + @swagger_auto_schema(auto_schema=None) + def post(self, request, encoding_id): + ret = {} + force = request.data.get("force", False) + task_id = request.data.get("task_id", False) + action = request.data.get("action", "") + chunk = request.data.get("chunk", False) + chunk_file_path = request.data.get("chunk_file_path", "") + + encoding_status = request.data.get("status", "") + progress = request.data.get("progress", "") + commands = request.data.get("commands", "") + logs = request.data.get("logs", "") + retries = request.data.get("retries", "") + worker = request.data.get("worker", "") + temp_file = request.data.get("temp_file", "") + total_run_time = request.data.get("total_run_time", "") + if action == "start": + try: + encoding = Encoding.objects.get(id=encoding_id) + media = encoding.media + profile = encoding.profile + except BaseException: + Encoding.objects.filter(id=encoding_id).delete() + return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) + # TODO: break chunk True/False logic here + if ( + Encoding.objects.filter( + media=media, + profile=profile, + chunk=chunk, + chunk_file_path=chunk_file_path, + ).count() + > 1 # noqa + and force is False # noqa + ): + Encoding.objects.filter(id=encoding_id).delete() + return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) + else: + Encoding.objects.filter( + media=media, + profile=profile, + chunk=chunk, + chunk_file_path=chunk_file_path, + ).exclude(id=encoding.id).delete() + + encoding.status = "running" + if task_id: + encoding.task_id = task_id + + encoding.save() + if chunk: + original_media_path = chunk_file_path + original_media_md5sum = encoding.md5sum + original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url + else: + original_media_path = media.media_file.path + original_media_md5sum = media.md5sum + original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url + + ret["original_media_url"] = original_media_url + ret["original_media_path"] = original_media_path + ret["original_media_md5sum"] = original_media_md5sum + + # generating the commands here, and will replace these with temporary + # files created on the remote server + tf = "TEMP_FILE_REPLACE" + tfpass = "TEMP_FPASS_FILE_REPLACE" + ffmpeg_commands = produce_ffmpeg_commands( + original_media_path, + media.media_info, + resolution=profile.resolution, + codec=profile.codec, + output_filename=tf, + pass_file=tfpass, + chunk=chunk, + ) + if not ffmpeg_commands: + encoding.delete() + return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) + + ret["duration"] = media.duration + ret["ffmpeg_commands"] = ffmpeg_commands + ret["profile_extension"] = profile.extension + return Response(ret, status=status.HTTP_201_CREATED) + elif action == "update_fields": + try: + encoding = Encoding.objects.get(id=encoding_id) + except BaseException: + return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) + to_update = ["size", "update_date"] + if encoding_status: + encoding.status = encoding_status + to_update.append("status") + if progress: + encoding.progress = progress + to_update.append("progress") + if logs: + encoding.logs = logs + to_update.append("logs") + if commands: + encoding.commands = commands + to_update.append("commands") + if task_id: + encoding.task_id = task_id + to_update.append("task_id") + if total_run_time: + encoding.total_run_time = total_run_time + to_update.append("total_run_time") + if worker: + encoding.worker = worker + to_update.append("worker") + if temp_file: + encoding.temp_file = temp_file + to_update.append("temp_file") + + if retries: + encoding.retries = retries + to_update.append("retries") + + try: + encoding.save(update_fields=to_update) + except BaseException: + return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST) + return Response({"status": "success"}, status=status.HTTP_201_CREATED) + + @swagger_auto_schema(auto_schema=None) + def put(self, request, encoding_id, format=None): + encoding_file = request.data["file"] + encoding = Encoding.objects.filter(id=encoding_id).first() + if not encoding: + return Response( + {"detail": "encoding does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + encoding.media_file = encoding_file + encoding.save() + return Response({"detail": "ok"}, status=status.HTTP_201_CREATED) + + +class EncodeProfileList(APIView): + """List encode profiles""" + + @swagger_auto_schema( + manual_parameters=[], + tags=['Encoding Profiles'], + operation_summary='List Encoding Profiles', + operation_description='Lists all encoding profiles for videos', + responses={200: EncodeProfileSerializer(many=True)}, + ) + def get(self, request, format=None): + profiles = EncodeProfile.objects.all() + serializer = EncodeProfileSerializer(profiles, many=True, context={"request": request}) + return Response(serializer.data) diff --git a/files/views/media.py b/files/views/media.py new file mode 100644 index 00000000..ca0b9d87 --- /dev/null +++ b/files/views/media.py @@ -0,0 +1,763 @@ +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.shortcuts import get_object_or_404 +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.parsers import ( + FileUploadParser, + FormParser, + JSONParser, + MultiPartParser, +) +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from actions.models import MediaAction +from cms.custom_pagination import FastPaginationWithoutCount +from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor +from users.models import User + +from .. import helpers +from ..methods import ( + change_media_owner, + copy_media, + get_user_or_session, + is_mediacms_editor, + show_recommended_media, + show_related_media, + update_user_ratings, +) +from ..models import EncodeProfile, Media, MediaPermission, Playlist, PlaylistMedia +from ..serializers import MediaSearchSerializer, MediaSerializer, SingleMediaSerializer +from ..stop_words import STOP_WORDS +from ..tasks import save_user_action + + +class MediaList(APIView): + """Media listings views""" + + permission_classes = (IsAuthorizedToAdd,) + parser_classes = (MultiPartParser, FormParser, FileUploadParser) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), + openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'), + openapi.Parameter(name='show', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='show', enum=['recommended', 'featured', 'latest']), + ], + tags=['Media'], + operation_summary='List Media', + operation_description='Lists all media', + responses={200: MediaSerializer(many=True)}, + ) + def _get_media_queryset(self, request, user=None): + base_filters = Q(listable=True) + if user: + base_filters &= Q(user=user) + + base_queryset = Media.objects.prefetch_related("user") + + if not request.user.is_authenticated: + return base_queryset.filter(base_filters).order_by("-add_date") + + # Build OR conditions for authenticated users + conditions = base_filters # Start with listable media + + # Add user permissions + permission_filter = {'user': request.user} + if user: + permission_filter['owner_user'] = user + + if MediaPermission.objects.filter(**permission_filter).exists(): + perm_conditions = Q(permissions__user=request.user) + if user: + 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) + if user: + rbac_conditions &= Q(user=user) + conditions |= rbac_conditions + + return base_queryset.filter(conditions).distinct().order_by("-add_date")[:1000] + + def get(self, request, format=None): + # Show media + # authenticated users can see: + + # All listable media (public access) + # Non-listable media they have RBAC access to + # Non-listable media they have direct permissions for + + params = self.request.query_params + show_param = params.get("show", "") + + author_param = params.get("author", "").strip() + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + + if show_param == "recommended": + pagination_class = FastPaginationWithoutCount + media = show_recommended_media(request, limit=50) + elif show_param == "featured": + media = Media.objects.filter(listable=True, featured=True).prefetch_related("user").order_by("-add_date") + 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") + 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) + + if getattr(settings, 'USE_RBAC', False): + rbac_categories = request.user.get_rbac_categories_as_member() + rbac_filters = {'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 + elif author_param: + user_queryset = User.objects.all() + user = get_object_or_404(user_queryset, username=author_param) + if self.request.user == user: + media = Media.objects.filter(user=user).prefetch_related("user").order_by("-add_date") + else: + media = self._get_media_queryset(request, user) + else: + media = self._get_media_queryset(request) + + paginator = pagination_class() + + page = paginator.paginate_queryset(media, request) + + serializer = MediaSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=True, description="media_file"), + openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"), + openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"), + ], + tags=['Media'], + operation_summary='Add new Media', + operation_description='Adds a new media, for authenticated users', + responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'}, + ) + def post(self, request, format=None): + # Add new media + serializer = MediaSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + media_file = request.data["media_file"] + serializer.save(user=request.user, media_file=media_file) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class MediaBulkUserActions(APIView): + """Bulk actions on media items""" + + permission_classes = (permissions.IsAuthenticated,) + parser_classes = (JSONParser,) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='media_ids', in_=openapi.IN_FORM, type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), required=True, description="List of media IDs"), + openapi.Parameter( + name='action', + in_=openapi.IN_FORM, + type=openapi.TYPE_STRING, + required=True, + description="Action to perform", + enum=[ + "enable_comments", + "disable_comments", + "delete_media", + "enable_download", + "disable_download", + "add_to_playlist", + "remove_from_playlist", + "set_state", + "change_owner", + "copy_media", + ], + ), + openapi.Parameter( + name='playlist_ids', + in_=openapi.IN_FORM, + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_INTEGER), + required=False, + description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)", + ), + openapi.Parameter( + name='state', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"] + ), + openapi.Parameter(name='owner', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="New owner username (required for change_owner action)"), + ], + tags=['Media'], + operation_summary='Perform bulk actions on media', + operation_description='Perform various bulk actions on multiple media items at once', + responses={ + 200: openapi.Response('Action performed successfully'), + 400: 'Bad request', + 401: 'Not authenticated', + }, + ) + 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"}) + + elif action == "disable_comments": + media.update(enable_comments=False) + return Response({"detail": f"Comments disabled for {media.count()} media items"}) + + elif action == "delete_media": + count = media.count() + media.delete() + return Response({"detail": f"{count} media items deleted"}) + + elif action == "enable_download": + media.update(allow_download=True) + return Response({"detail": f"Download enabled for {media.count()} media items"}) + + elif action == "disable_download": + media.update(allow_download=False) + return Response({"detail": f"Download disabled for {media.count()} media items"}) + + elif action == "add_to_playlist": + playlist_ids = request.data.get('playlist_ids', []) + if not playlist_ids: + return Response({"detail": "playlist_ids is required for add_to_playlist action"}, status=status.HTTP_400_BAD_REQUEST) + + playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids) + if not playlists: + return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST) + + added_count = 0 + for playlist in playlists: + for m in media: + media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count() + if media_in_playlist < settings.MAX_MEDIA_PER_PLAYLIST: + obj, created = PlaylistMedia.objects.get_or_create( + playlist=playlist, + media=m, + ordering=media_in_playlist + 1, + ) + if created: + added_count += 1 + + return Response({"detail": f"Added {added_count} media items to {playlists.count()} playlists"}) + + elif action == "remove_from_playlist": + playlist_ids = request.data.get('playlist_ids', []) + if not playlist_ids: + return Response({"detail": "playlist_ids is required for remove_from_playlist action"}, status=status.HTTP_400_BAD_REQUEST) + + playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids) + if not playlists: + return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST) + + removed_count = 0 + for playlist in playlists: + removed = PlaylistMedia.objects.filter(playlist=playlist, media__in=media).delete()[0] + removed_count += removed + + return Response({"detail": f"Removed {removed_count} media items from {playlists.count()} playlists"}) + + elif action == "set_state": + state = request.data.get('state') + if not state: + return Response({"detail": "state is required for set_state action"}, status=status.HTTP_400_BAD_REQUEST) + + valid_states = ["private", "public", "unlisted"] + 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: + m.listable = True + else: + m.listable = False + + m.save(update_fields=["state", "listable"]) + + return Response({"detail": f"State updated to {state} for {media.count()} media items"}) + + elif action == "change_owner": + owner = request.data.get('owner') + if not owner: + return Response({"detail": "owner is required for change_owner action"}, status=status.HTTP_400_BAD_REQUEST) + + new_user = User.objects.filter(username=owner).first() + if not new_user: + return Response({"detail": "User not found"}, status=status.HTTP_400_BAD_REQUEST) + + changed_count = 0 + for m in media: + result = change_media_owner(m.id, new_user) + if result: + changed_count += 1 + + return Response({"detail": f"Owner changed for {changed_count} media items"}) + + elif action == "copy_media": + for m in media: + copy_media(m.id) + + return Response({"detail": f"{media.count()} media items copied"}) + + else: + return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST) + + +class MediaDetail(APIView): + """ + Retrieve, update or delete a media instance. + """ + + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor) + parser_classes = (MultiPartParser, FormParser, FileUploadParser) + + def get_object(self, friendly_token): + try: + media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token) + + # this need be explicitly called, and will call + # has_object_permission() after has_permission has succeeded + self.check_object_permissions(self.request, media) + if media.state == "private": + if self.request.user.has_member_access_to_media(media) or is_mediacms_editor(self.request.user): + pass + else: + return Response( + {"detail": "media is private"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + return media + except PermissionDenied: + return Response({"detail": "bad permissions"}, status=status.HTTP_401_UNAUTHORIZED) + except BaseException: + return Response( + {"detail": "media file does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), + ], + tags=['Media'], + operation_summary='Get information for Media', + operation_description='Get information for a media', + responses={200: SingleMediaSerializer(), 400: 'bad request'}, + ) + def get(self, request, friendly_token, format=None): + # Get media details + # password = request.GET.get("password") + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + serializer = SingleMediaSerializer(media, context={"request": request}) + if media.state == "private": + related_media = [] + else: + related_media = show_related_media(media, request=request, limit=100) + related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request}) + related_media = related_media_serializer.data + ret = serializer.data + + # update rattings info with user specific ratings + # eg user has already rated for this media + # this only affects user rating and only if enabled + if settings.ALLOW_RATINGS and ret.get("ratings_info") and not request.user.is_anonymous: + ret["ratings_info"] = update_user_ratings(request.user, media, ret.get("ratings_info")) + + ret["related_media"] = related_media + return Response(ret) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), + openapi.Parameter(name='type', type=openapi.TYPE_STRING, in_=openapi.IN_FORM, description='action to perform', enum=['encode', 'review']), + openapi.Parameter( + name='encoding_profiles', + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_STRING), + in_=openapi.IN_FORM, + description='if action to perform is encode, need to specify list of ids of encoding profiles', + ), + openapi.Parameter(name='result', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_FORM, description='if action is review, this is the result (True for reviewed, False for not reviewed)'), + ], + tags=['Media'], + operation_summary='Run action on Media', + operation_description='Actions for a media, for MediaCMS editors and managers', + responses={201: 'action created', 400: 'bad request'}, + operation_id='media_manager_actions', + ) + def post(self, request, friendly_token, format=None): + """superuser actions + Available only to MediaCMS editors and managers + + Action is a POST variable, review and encode are implemented + """ + + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + if not is_mediacms_editor(request.user): + return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + action = request.data.get("type") + profiles_list = request.data.get("encoding_profiles") + result = request.data.get("result", True) + if action == "encode": + # Create encoding tasks for specific profiles + valid_profiles = [] + if profiles_list: + if isinstance(profiles_list, list): + for p in profiles_list: + p = EncodeProfile.objects.filter(id=p).first() + if p: + valid_profiles.append(p) + elif isinstance(profiles_list, str): + try: + p = EncodeProfile.objects.filter(id=int(profiles_list)).first() + valid_profiles.append(p) + except ValueError: + return Response( + {"detail": "encoding_profiles must be int or list of ints of valid encode profiles"}, + status=status.HTTP_400_BAD_REQUEST, + ) + media.encode(profiles=valid_profiles) + return Response({"detail": "media will be encoded"}, status=status.HTTP_201_CREATED) + elif action == "review": + if result: + media.is_reviewed = True + elif result is False: + media.is_reviewed = False + media.save(update_fields=["is_reviewed"]) + return Response({"detail": "media reviewed set"}, status=status.HTTP_201_CREATED) + return Response( + {"detail": "not valid action or no action specified"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"), + openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"), + openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=False, description="media_file"), + ], + tags=['Media'], + operation_summary='Update Media', + operation_description='Update a Media, for Media uploader', + responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'}, + ) + def put(self, request, friendly_token, format=None): + # Update a media object + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)): + return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + 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) + # else: + # serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True), + ], + tags=['Media'], + operation_summary='Delete Media', + operation_description='Delete a Media, for MediaCMS editors and managers', + responses={ + 204: 'no content', + }, + ) + def delete(self, request, friendly_token, format=None): + # Delete a media object + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + media.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MediaActions(APIView): + """ + Retrieve, update or delete a media action instance. + """ + + permission_classes = (permissions.AllowAny,) + parser_classes = (JSONParser,) + + def get_object(self, friendly_token): + try: + media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token) + if media.state == "private" and self.request.user != media.user: + return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST) + return media + except PermissionDenied: + return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST) + except BaseException: + return Response( + {"detail": "media file does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def get(self, request, friendly_token, format=None): + # show date and reason for each time media was reported + media = self.get_object(friendly_token) + if not (request.user == media.user or is_mediacms_editor(request.user)): + return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + if isinstance(media, Response): + return media + + ret = {} + reported = MediaAction.objects.filter(media=media, action="report") + ret["reported"] = [] + for rep in reported: + item = {"reported_date": rep.action_date, "reason": rep.extra_info} + ret["reported"].append(item) + + return Response(ret, status=status.HTTP_200_OK) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def post(self, request, friendly_token, format=None): + # perform like/dislike/report actions + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + action = request.data.get("type") + extra = request.data.get("extra_info") + if request.user.is_anonymous: + # there is a list of allowed actions for + # anonymous users, specified in settings + if action not in settings.ALLOW_ANONYMOUS_ACTIONS: + return Response( + {"detail": "action allowed on logged in users only"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if action: + user_or_session = get_user_or_session(request) + save_user_action.delay( + user_or_session, + friendly_token=media.friendly_token, + action=action, + extra_info=extra, + ) + + return Response({"detail": "action received"}, status=status.HTTP_201_CREATED) + else: + return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Media'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def delete(self, request, friendly_token, format=None): + media = self.get_object(friendly_token) + if isinstance(media, Response): + return media + + if not request.user.is_superuser: + return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) + + action = request.data.get("type") + if action: + if action == "report": # delete reported actions + MediaAction.objects.filter(media=media, action="report").delete() + media.reported_times = 0 + media.save(update_fields=["reported_times"]) + return Response( + {"detail": "reset reported times counter"}, + status=status.HTTP_201_CREATED, + ) + else: + return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST) + + +class MediaSearch(APIView): + """ + Retrieve results for search + Only GET is implemented here + """ + + parser_classes = (JSONParser,) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Search'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def get(self, request, format=None): + params = self.request.query_params + query = params.get("q", "").strip().lower() + category = params.get("c", "").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() + + 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 = "-" + + if media_type not in ["video", "image", "audio", "pdf"]: + media_type = None + + if not (query or category or tag): + ret = {} + return Response(ret, status=status.HTTP_200_OK) + + if request.user.is_authenticated: + basic_query = Q(listable=True) | Q(permissions__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) + + else: + basic_query = Q(listable=True) + + media = Media.objects.filter(basic_query).distinct() + + if query: + # move this processing to a prepare_query function + 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 category: + media = media.filter(category__title__contains=category) + + if media_type: + media = media.filter(media_type=media_type) + + if author: + media = media.filter(user__username=author) + + if upload_date: + gte = None + 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) + if gte: + media = media.filter(add_date__gte=gte) + + media = media.order_by(f"{ordering}{sort_by}") + + if self.request.query_params.get("show", "").strip() == "titles": + media = media.values("title")[:40] + return Response(media, status=status.HTTP_200_OK) + else: + media = media.prefetch_related("user")[:1000] # limit to 1000 results + + if category or tag: + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + else: + # pagination_class = FastPaginationWithoutCount + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + page = paginator.paginate_queryset(media, request) + serializer = MediaSearchSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) diff --git a/files/views/pages.py b/files/views/pages.py new file mode 100644 index 00000000..de382f64 --- /dev/null +++ b/files/views/pages.py @@ -0,0 +1,593 @@ +import json + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.mail import EmailMessage +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt + +from cms.permissions import user_allowed_to_upload +from cms.version import VERSION +from users.models import User + +from .. import helpers +from ..forms import ( + ContactForm, + EditSubtitleForm, + MediaMetadataForm, + MediaPublishForm, + SubtitleForm, +) +from ..frontend_translations import translate_string +from ..helpers import get_alphanumeric_only +from ..methods import ( + create_video_trim_request, + get_user_or_session, + handle_video_chapters, + is_mediacms_editor, +) +from ..models import Category, Media, Playlist, Subtitle, Tag, VideoTrimRequest +from ..tasks import save_user_action, video_trim_task + + +def about(request): + """About view""" + + context = {"VERSION": VERSION} + return render(request, "cms/about.html", context) + + +def setlanguage(request): + """Set Language view""" + + context = {} + return render(request, "cms/set_language.html", context) + + +@login_required +def add_subtitle(request): + """Add subtitle view""" + + 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 == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + if request.method == "POST": + form = SubtitleForm(media, request.POST, request.FILES) + if form.is_valid(): + subtitle = form.save() + new_subtitle = Subtitle.objects.filter(id=subtitle.id).first() + try: + new_subtitle.convert_to_srt() + messages.add_message(request, messages.INFO, "Subtitle was added!") + return HttpResponseRedirect(subtitle.media.get_absolute_url()) + except: # noqa: E722 + new_subtitle.delete() + error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files." + form.add_error("subtitle_file", error_msg) + + else: + form = SubtitleForm(media_item=media) + subtitles = media.subtitles.all() + context = {"media": media, "form": form, "subtitles": subtitles} + return render(request, "cms/add_subtitle.html", context) + + +@login_required +def edit_subtitle(request): + subtitle_id = request.GET.get("id", "").strip() + action = request.GET.get("action", "").strip() + if not subtitle_id: + return HttpResponseRedirect("/") + subtitle = Subtitle.objects.filter(id=subtitle_id).first() + + if not subtitle: + return HttpResponseRedirect("/") + + if not (request.user == subtitle.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + context = {"subtitle": subtitle, "action": action} + + if action == "download": + response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt") + filename = subtitle.subtitle_file.name.split("/")[-1] + + if not filename.endswith(".vtt"): + filename = f"{filename}.vtt" + + response["Content-Disposition"] = f"attachment; filename={filename}" # noqa + + return response + + if request.method == "GET": + form = EditSubtitleForm(subtitle) + context["form"] = form + elif request.method == "POST": + confirm = request.GET.get("confirm", "").strip() + if confirm == "true": + messages.add_message(request, messages.INFO, "Subtitle was deleted") + redirect_url = subtitle.media.get_absolute_url() + subtitle.delete() + return HttpResponseRedirect(redirect_url) + form = EditSubtitleForm(subtitle, request.POST) + subtitle_text = form.data["subtitle"] + with open(subtitle.subtitle_file.path, "w") as ff: + ff.write(subtitle_text) + + messages.add_message(request, messages.INFO, "Subtitle was edited") + return HttpResponseRedirect(subtitle.media.get_absolute_url()) + return render(request, "cms/edit_subtitle.html", context) + + +def categories(request): + """List categories view""" + + context = {} + return render(request, "cms/categories.html", context) + + +def contact(request): + """Contact view""" + + context = {} + if request.method == "GET": + form = ContactForm(request.user) + context["form"] = form + + else: + form = ContactForm(request.user, request.POST) + if form.is_valid(): + if request.user.is_authenticated: + from_email = request.user.email + name = request.user.name + else: + from_email = request.POST.get("from_email") + name = request.POST.get("name") + message = request.POST.get("message") + + title = f"[{settings.PORTAL_NAME}] - Contact form message received" + + msg = """ +You have received a message through the contact form\n +Sender name: %s +Sender email: %s\n +\n %s +""" % ( + name, + from_email, + message, + ) + email = EmailMessage( + title, + msg, + settings.DEFAULT_FROM_EMAIL, + settings.ADMIN_EMAIL_LIST, + reply_to=[from_email], + ) + email.send(fail_silently=True) + success_msg = "Message was sent! Thanks for contacting" + context["success_msg"] = success_msg + + return render(request, "cms/contact.html", context) + + +def history(request): + """Show personal history view""" + + context = {} + return render(request, "cms/history.html", context) + + +@csrf_exempt +@login_required +def video_chapters(request, friendly_token): + # this is not ready... + return False + if not request.method == "POST": + return HttpResponseRedirect("/") + + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + try: + data = json.loads(request.body)["chapters"] + chapters = [] + for _, chapter_data in enumerate(data): + start_time = chapter_data.get('start') + title = chapter_data.get('title') + if start_time and title: + chapters.append( + { + 'start': start_time, + 'title': title, + } + ) + except Exception as e: # noqa + return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400) + + ret = handle_video_chapters(media, chapters) + + return JsonResponse(ret, safe=False) + + +@login_required +def edit_media(request): + """Edit a media view""" + + 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 request.method == "POST": + form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media) + if form.is_valid(): + media = form.save() + for tag in media.tags.all(): + media.tags.remove(tag) + if form.cleaned_data.get("new_tags"): + for tag in form.cleaned_data.get("new_tags").split(","): + tag = get_alphanumeric_only(tag) + tag = tag[:99] + if tag: + try: + tag = Tag.objects.get(title=tag) + except Tag.DoesNotExist: + tag = Tag.objects.create(title=tag, user=request.user) + if tag not in media.tags.all(): + media.tags.add(tag) + messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) + return HttpResponseRedirect(media.get_absolute_url()) + else: + form = MediaMetadataForm(request.user, instance=media) + return render( + request, + "cms/edit_media.html", + {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, + ) + + +@login_required +def publish_media(request): + """Publish media""" + + 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 request.method == "POST": + form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media) + if form.is_valid(): + media = form.save() + messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) + return HttpResponseRedirect(media.get_absolute_url()) + else: + form = MediaPublishForm(request.user, instance=media) + + return render( + request, + "cms/publish_media.html", + {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, + ) + + +@login_required +def edit_chapters(request): + """Edit chapters""" + # not implemented yet + return False + 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 == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + return render( + request, + "cms/edit_chapters.html", + {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token}, + ) + + +@csrf_exempt +@login_required +def trim_video(request, friendly_token): + if not settings.ALLOW_VIDEO_TRIMMER: + return JsonResponse({"success": False, "error": "Video trimming is not allowed"}, status=400) + + if not request.method == "POST": + return HttpResponseRedirect("/") + + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() + + if existing_requests: + return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400) + + try: + data = json.loads(request.body) + video_trim_request = create_video_trim_request(media, data) + video_trim_task.delay(video_trim_request.id) + ret = {"success": True, "request_id": video_trim_request.id} + return JsonResponse(ret, safe=False, status=200) + except Exception as e: # noqa + ret = {"success": False, "error": "Incorrect request data"} + return JsonResponse(ret, safe=False, status=400) + + +@login_required +def edit_video(request): + """Edit video""" + + 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 == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + if not media.media_type == "video": + messages.add_message(request, messages.INFO, "Media is not video") + return HttpResponseRedirect(media.get_absolute_url()) + + if not settings.ALLOW_VIDEO_TRIMMER: + messages.add_message(request, messages.INFO, "Video Trimmer is not enabled") + return HttpResponseRedirect(media.get_absolute_url()) + + # Check if there's a running trim request + running_trim_request = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() + + if running_trim_request: + messages.add_message(request, messages.INFO, "Video trim request is already running") + return HttpResponseRedirect(media.get_absolute_url()) + + media_file_path = media.trim_video_url + + if not media_file_path: + messages.add_message(request, messages.INFO, "Media processing has not finished yet") + return HttpResponseRedirect(media.get_absolute_url()) + + if media.encoding_status in ["pending", "running"]: + video_msg = "Media encoding hasn't finished yet. Attempting to show the original video file" + messages.add_message(request, messages.INFO, video_msg) + + return render( + request, + "cms/edit_video.html", + {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path}, + ) + + +def embed_media(request): + """Embed media view""" + + friendly_token = request.GET.get("m", "").strip() + if not friendly_token: + return HttpResponseRedirect("/") + + media = Media.objects.values("title").filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + context = {} + context["media"] = friendly_token + return render(request, "cms/embed.html", context) + + +def featured_media(request): + """List featured media view""" + + context = {} + return render(request, "cms/featured-media.html", context) + + +def index(request): + """Index view""" + + context = {} + return render(request, "cms/index.html", context) + + +def latest_media(request): + """List latest media view""" + + context = {} + return render(request, "cms/latest-media.html", context) + + +def liked_media(request): + """List user's liked media view""" + + context = {} + return render(request, "cms/liked_media.html", context) + + +@login_required +def manage_users(request): + """List users management view""" + + if not is_mediacms_editor(request.user): + return HttpResponseRedirect("/") + + context = {} + return render(request, "cms/manage_users.html", context) + + +@login_required +def manage_media(request): + """List media management view""" + if not is_mediacms_editor(request.user): + return HttpResponseRedirect("/") + + categories = Category.objects.all().order_by('title').values_list('title', flat=True) + context = {'categories': list(categories)} + return render(request, "cms/manage_media.html", context) + + +@login_required +def manage_comments(request): + """List comments management view""" + if not is_mediacms_editor(request.user): + return HttpResponseRedirect("/") + + context = {} + return render(request, "cms/manage_comments.html", context) + + +def members(request): + """List members view""" + + context = {} + return render(request, "cms/members.html", context) + + +def recommended_media(request): + """List recommended media view""" + + context = {} + return render(request, "cms/recommended-media.html", context) + + +def search(request): + """Search view""" + + context = {} + RSS_URL = f"/rss{request.environ.get('REQUEST_URI')}" + context["RSS_URL"] = RSS_URL + return render(request, "cms/search.html", context) + + +def sitemap(request): + """Sitemap""" + + context = {} + context["media"] = list(Media.objects.filter(listable=True).order_by("-add_date")) + context["playlists"] = list(Playlist.objects.filter().order_by("-add_date")) + context["users"] = list(User.objects.filter()) + return render(request, "sitemap.xml", context, content_type="application/xml") + + +def tags(request): + """List tags view""" + + context = {} + return render(request, "cms/tags.html", context) + + +def tos(request): + """Terms of service view""" + + context = {} + return render(request, "cms/tos.html", context) + + +@login_required +def upload_media(request): + """Upload media view""" + + from allauth.account.forms import LoginForm + + form = LoginForm() + context = {} + context["form"] = form + context["can_add"] = user_allowed_to_upload(request) + can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE + context["can_upload_exp"] = can_upload_exp + + return render(request, "cms/add-media.html", context) + + +def view_media(request): + """View media view""" + + friendly_token = request.GET.get("m", "").strip() + context = {} + media = Media.objects.filter(friendly_token=friendly_token).first() + if not media: + context["media"] = None + return render(request, "cms/media.html", context) + + user_or_session = get_user_or_session(request) + save_user_action.delay(user_or_session, friendly_token=friendly_token, action="watch") + context = {} + context["media"] = friendly_token + context["media_object"] = media + + context["CAN_DELETE_MEDIA"] = False + context["CAN_EDIT_MEDIA"] = False + context["CAN_DELETE_COMMENTS"] = False + + 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 + + # 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': + video_msg = None + if media.encoding_status == "pending": + video_msg = "Media encoding hasn't started yet. Attempting to show the original video file" + if media.encoding_status == "running": + video_msg = "Media encoding is under processing. Attempting to show the original video file" + if video_msg: + messages.add_message(request, messages.INFO, video_msg) + + return render(request, "cms/media.html", context) + + +def view_playlist(request, friendly_token): + """View playlist view""" + + try: + playlist = Playlist.objects.get(friendly_token=friendly_token) + except BaseException: + playlist = None + + context = {} + context["playlist"] = playlist + return render(request, "cms/playlist.html", context) diff --git a/files/views/playlists.py b/files/views/playlists.py new file mode 100644 index 00000000..8b2d9feb --- /dev/null +++ b/files/views/playlists.py @@ -0,0 +1,195 @@ +from django.conf import settings +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied +from rest_framework.parsers import ( + FileUploadParser, + FormParser, + JSONParser, + MultiPartParser, +) +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor + +from ..models import Media, Playlist, PlaylistMedia +from ..serializers import MediaSerializer, PlaylistDetailSerializer, PlaylistSerializer + + +class PlaylistList(APIView): + """Playlists listings and creation views""" + + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd) + parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + responses={ + 200: openapi.Response('response description', PlaylistSerializer(many=True)), + }, + ) + def get(self, request, format=None): + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + playlists = Playlist.objects.filter().prefetch_related("user") + + if "author" in self.request.query_params: + author = self.request.query_params["author"].strip() + playlists = playlists.filter(user__username=author) + + page = paginator.paginate_queryset(playlists, request) + + serializer = PlaylistSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def post(self, request, format=None): + serializer = PlaylistSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PlaylistDetail(APIView): + """Playlist related views""" + + permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor) + parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + + def get_playlist(self, friendly_token): + try: + playlist = Playlist.objects.get(friendly_token=friendly_token) + self.check_object_permissions(self.request, playlist) + return playlist + except PermissionDenied: + return Response({"detail": "not enough permissions"}, status=status.HTTP_400_BAD_REQUEST) + except BaseException: + return Response( + {"detail": "Playlist does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def get(self, request, friendly_token, format=None): + playlist = self.get_playlist(friendly_token) + if isinstance(playlist, Response): + return playlist + + serializer = PlaylistDetailSerializer(playlist, context={"request": request}) + + playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user") + + playlist_media = [c.media for c in playlist_media] + + playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request}) + ret = serializer.data + ret["playlist_media"] = playlist_media_serializer.data + + return Response(ret) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def post(self, request, friendly_token, format=None): + playlist = self.get_playlist(friendly_token) + if isinstance(playlist, Response): + return playlist + serializer = PlaylistDetailSerializer(playlist, data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def put(self, request, friendly_token, format=None): + playlist = self.get_playlist(friendly_token) + if isinstance(playlist, Response): + return playlist + action = request.data.get("type") + media_friendly_token = request.data.get("media_friendly_token") + ordering = 0 + if request.data.get("ordering"): + try: + ordering = int(request.data.get("ordering")) + except ValueError: + pass + + if action in ["add", "remove", "ordering"]: + media = Media.objects.filter(friendly_token=media_friendly_token).first() + if media: + if action == "add": + media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count() + if media_in_playlist >= settings.MAX_MEDIA_PER_PLAYLIST: + return Response( + {"detail": "max number of media for a Playlist reached"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + obj, created = PlaylistMedia.objects.get_or_create( + playlist=playlist, + media=media, + ordering=media_in_playlist + 1, + ) + obj.save() + return Response( + {"detail": "media added to Playlist"}, + status=status.HTTP_201_CREATED, + ) + elif action == "remove": + PlaylistMedia.objects.filter(playlist=playlist, media=media).delete() + return Response( + {"detail": "media removed from Playlist"}, + status=status.HTTP_201_CREATED, + ) + elif action == "ordering": + if ordering: + playlist.set_ordering(media, ordering) + return Response( + {"detail": "new ordering set"}, + status=status.HTTP_201_CREATED, + ) + else: + return Response({"detail": "media is not valid"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "invalid or not specified action"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @swagger_auto_schema( + manual_parameters=[], + tags=['Playlists'], + operation_summary='to_be_written', + operation_description='to_be_written', + ) + def delete(self, request, friendly_token, format=None): + playlist = self.get_playlist(friendly_token) + if isinstance(playlist, Response): + return playlist + + playlist.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/files/views/tasks.py b/files/views/tasks.py new file mode 100644 index 00000000..4260b2da --- /dev/null +++ b/files/views/tasks.py @@ -0,0 +1,30 @@ +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..methods import list_tasks + + +class TasksList(APIView): + """List tasks""" + + swagger_schema = None + + permission_classes = (permissions.IsAdminUser,) + + def get(self, request, format=None): + ret = list_tasks() + return Response(ret) + + +class TaskDetail(APIView): + """Cancel a task""" + + swagger_schema = None + + permission_classes = (permissions.IsAdminUser,) + + def delete(self, request, uid, format=None): + # This is not imported! + # revoke(uid, terminate=True) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/files/views/user.py b/files/views/user.py new file mode 100644 index 00000000..c4d90527 --- /dev/null +++ b/files/views/user.py @@ -0,0 +1,45 @@ +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.parsers import JSONParser +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from actions.models import USER_MEDIA_ACTIONS + +from ..models import Media +from ..serializers import MediaSerializer + +VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS] + + +class UserActions(APIView): + parser_classes = (JSONParser,) + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter(name='action', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='action', required=True, enum=VALID_USER_ACTIONS), + ], + tags=['Users'], + operation_summary='List user actions', + operation_description='Lists user actions', + ) + def get(self, request, action): + media = [] + if action in VALID_USER_ACTIONS: + if request.user.is_authenticated: + media = Media.objects.select_related("user").filter(mediaactions__user=request.user, mediaactions__action=action).order_by("-mediaactions__action_date") + elif request.session.session_key: + media = ( + Media.objects.select_related("user") + .filter( + mediaactions__session_key=request.session.session_key, + mediaactions__action=action, + ) + .order_by("-mediaactions__action_date") + ) + + pagination_class = api_settings.DEFAULT_PAGINATION_CLASS + paginator = pagination_class() + page = paginator.paginate_queryset(media, request) + serializer = MediaSerializer(page, many=True, context={"request": request}) + return paginator.get_paginated_response(serializer.data) diff --git a/fixtures/test_image2.jpg b/fixtures/test_image2.jpg new file mode 100644 index 00000000..126a8f7e Binary files /dev/null and b/fixtures/test_image2.jpg differ diff --git a/templates/base.html b/templates/base.html index 5767344d..1915417d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,4 +19,4 @@ {% include "components/footer.html" %} {% endblock %} -{% block bottomimports %}{% endblock bottomimports %} \ No newline at end of file +{% block bottomimports %}{% endblock bottomimports %} \ No newline at end of file diff --git a/templates/cms/add-media.html b/templates/cms/add-media.html index 24a1ea40..fdc0c502 100644 --- a/templates/cms/add-media.html +++ b/templates/cms/add-media.html @@ -129,7 +129,7 @@ {% endblock innercontent %} {% block bottomimports %} - + + {% endblock bottomimports %} diff --git a/templates/cms/embed.html b/templates/cms/embed.html index 53173ab1..2e1e83d1 100644 --- a/templates/cms/embed.html +++ b/templates/cms/embed.html @@ -9,5 +9,5 @@ {% block content %}
{% endblock content %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/featured-media.html b/templates/cms/featured-media.html index e5410782..dcb2ea46 100644 --- a/templates/cms/featured-media.html +++ b/templates/cms/featured-media.html @@ -43,5 +43,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/history.html b/templates/cms/history.html index 1e6ea0b9..4ee501f8 100644 --- a/templates/cms/history.html +++ b/templates/cms/history.html @@ -13,5 +13,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/index.html b/templates/cms/index.html index 181a65bb..5a9e5c84 100644 --- a/templates/cms/index.html +++ b/templates/cms/index.html @@ -48,5 +48,5 @@ {% block content %}
{% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/latest-media.html b/templates/cms/latest-media.html index 02095b12..d5e46fe1 100644 --- a/templates/cms/latest-media.html +++ b/templates/cms/latest-media.html @@ -43,5 +43,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/liked_media.html b/templates/cms/liked_media.html index d6076fd5..928bbb5b 100644 --- a/templates/cms/liked_media.html +++ b/templates/cms/liked_media.html @@ -13,5 +13,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/manage_comments.html b/templates/cms/manage_comments.html index 52a509d6..d051193c 100644 --- a/templates/cms/manage_comments.html +++ b/templates/cms/manage_comments.html @@ -14,5 +14,5 @@ {% block content %}
{% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/manage_media.html b/templates/cms/manage_media.html index bb82f886..a9f0af58 100644 --- a/templates/cms/manage_media.html +++ b/templates/cms/manage_media.html @@ -19,6 +19,6 @@ window.CATEGORIES = {{ categories|safe }}; {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/manage_users.html b/templates/cms/manage_users.html index b5685b31..b22c074a 100644 --- a/templates/cms/manage_users.html +++ b/templates/cms/manage_users.html @@ -14,5 +14,5 @@ {% block content %}
{% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/media.html b/templates/cms/media.html index 7953aca6..03c25d5f 100644 --- a/templates/cms/media.html +++ b/templates/cms/media.html @@ -129,5 +129,5 @@ {% block content %}
{% endblock content %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/members.html b/templates/cms/members.html index 8084b4c1..b7cb157d 100644 --- a/templates/cms/members.html +++ b/templates/cms/members.html @@ -41,5 +41,5 @@ {% block content %}
{% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/playlist.html b/templates/cms/playlist.html index 00e75406..88eb3aed 100755 --- a/templates/cms/playlist.html +++ b/templates/cms/playlist.html @@ -13,5 +13,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/recommended-media.html b/templates/cms/recommended-media.html index 8b048dd6..7d4b398f 100644 --- a/templates/cms/recommended-media.html +++ b/templates/cms/recommended-media.html @@ -43,5 +43,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/search.html b/templates/cms/search.html index 5b60c8be..334f9dfa 100644 --- a/templates/cms/search.html +++ b/templates/cms/search.html @@ -6,5 +6,5 @@ {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/tags.html b/templates/cms/tags.html index 620d31bb..b07e1ecb 100644 --- a/templates/cms/tags.html +++ b/templates/cms/tags.html @@ -41,5 +41,5 @@ {% block content %}
{% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/user.html b/templates/cms/user.html index c9a7d010..56073a8b 100644 --- a/templates/cms/user.html +++ b/templates/cms/user.html @@ -29,5 +29,5 @@ No such user {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} \ No newline at end of file diff --git a/templates/cms/user_about.html b/templates/cms/user_about.html index 53f311aa..c870dda7 100644 --- a/templates/cms/user_about.html +++ b/templates/cms/user_about.html @@ -29,5 +29,5 @@ No such user {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/user_playlists.html b/templates/cms/user_playlists.html index fd2d8ea6..17cdb8ee 100755 --- a/templates/cms/user_playlists.html +++ b/templates/cms/user_playlists.html @@ -29,5 +29,5 @@ No such user {% endblock %} {% block bottomimports %} - + {% endblock bottomimports %} diff --git a/templates/cms/user_shared_by_me.html b/templates/cms/user_shared_by_me.html new file mode 100644 index 00000000..6e03cd54 --- /dev/null +++ b/templates/cms/user_shared_by_me.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load static %} + +{% block headtitle %}{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}{% endblock headtitle %} + +{% block headermeta %} + + + + + + + +{% endblock headermeta %} + +{% block topimports %} +{% load static %} + + +{%endblock topimports %} + +{% block innercontent %} +{% if user %}{% else %} +No such user +{% endif %} +{% endblock %} + +{% block content %} +{% if user %} +
+{% endif %} + +{% endblock %} + +{% block bottomimports %} + +{% endblock bottomimports %} diff --git a/templates/cms/user_shared_with_me.html b/templates/cms/user_shared_with_me.html new file mode 100644 index 00000000..4fce2b2b --- /dev/null +++ b/templates/cms/user_shared_with_me.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load static %} + +{% block headtitle %}{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}{% endblock headtitle %} + +{% block headermeta %} + + + + + + + +{% endblock headermeta %} + +{% block topimports %} +{% load static %} + + +{%endblock topimports %} + +{% block innercontent %} +{% if user %}{% else %} +No such user +{% endif %} +{% endblock %} + +{% block content %} +{% if user %}
{% endif %} +{% endblock %} + +{% block bottomimports %} + +{% endblock bottomimports %} diff --git a/tests/api/test_media_listings.py b/tests/api/test_media_listings.py new file mode 100644 index 00000000..3a7f74dd --- /dev/null +++ b/tests/api/test_media_listings.py @@ -0,0 +1,67 @@ +from django.core.files import File +from django.test import Client, TestCase + +from files.models import Media +from files.tests import create_account + + +class TestMediaListings(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.client = Client() + self.password = 'this_is_a_fake_password' + self.user = create_account(password=self.password) + + # Create a test media item + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + self.media = Media.objects.create( + title="Test Media", description="Test Description", user=self.user, state="public", encoding_status="success", is_reviewed=True, listable=True, media_file=myfile + ) + + def test_media_list_endpoint(self): + """Test the media list API endpoint""" + url = '/api/v1/media' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Media list endpoint should return 200") + self.assertIn('results', response.data, "Response should contain results") + self.assertIn('count', response.data, "Response should contain count") + + # Check if our test media is in the results + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media.title, media_titles, "Test media should be in the results") + + def test_featured_media_listing(self): + """Test the featured media listing""" + # Mark our test media as featured + self.media.featured = True + self.media.save() + + url = '/api/v1/media?show=featured' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Featured media endpoint should return 200") + + # Check if our featured media is in the results + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media.title, media_titles, "Featured media should be in the results") + + def test_user_media_listing(self): + """Test listing media for a specific user""" + url = f'/api/v1/media?author={self.user.username}' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "User media endpoint should return 200") + + # Check if our user's media is in the results + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media.title, media_titles, "User's media should be in the results") + + def test_recommended_media_listing(self): + """Test the recommended media listing""" + url = '/api/v1/media?show=recommended' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Recommended media endpoint should return 200") diff --git a/tests/api/test_media_listings_TOWRITE.py b/tests/api/test_media_listings_TOWRITE.py deleted file mode 100644 index d6bbfdb4..00000000 --- a/tests/api/test_media_listings_TOWRITE.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.test import TestCase - - -class TestX(TestCase): - fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] - - def test_X(self): - # test a number of listings works (index, featured, user etc) - - pass diff --git a/tests/api/test_search.py b/tests/api/test_search.py new file mode 100644 index 00000000..e14f998f --- /dev/null +++ b/tests/api/test_search.py @@ -0,0 +1,114 @@ +from django.core.files import File +from django.test import Client, TestCase + +from files.models import Category, Media, Tag +from files.tests import create_account + + +class TestSearch(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.client = Client() + self.password = 'this_is_a_fake_password' + self.user = create_account(password=self.password) + + # Create test media items with different attributes for search testing + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + self.media1 = Media.objects.create(title="Python Tutorial", description="Learn Python programming", user=self.user, media_file=myfile) + + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + self.media2 = Media.objects.create( + title="Django Framework", + description="Web development with Django", + user=self.user, + media_file=myfile, + ) + + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + self.media3 = Media.objects.create( + title="JavaScript Basics", + description="Introduction to JavaScript", + user=self.user, + media_file=myfile, + ) + # Add categories and tags + self.category = Category.objects.first() + self.tag = Tag.objects.create(title="programming", user=self.user) + + self.media1.category.add(self.category) + self.media2.category.add(self.category) + self.media1.tags.add(self.tag) + self.media2.tags.add(self.tag) + + # Update search vectors + self.media1.update_search_vector() + self.media2.update_search_vector() + self.media3.update_search_vector() + + def test_search_by_title(self): + """Test searching media by title""" + url = '/api/v1/search?q=python' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Search endpoint should return 200") + + # Check if our media with "Python" in the title is in the results + + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media1.title, media_titles, "Media with 'Python' in title should be in results") + self.assertNotIn(self.media3.title, media_titles, "Media without 'Python' should not be in results") + + def test_search_by_category(self): + """Test searching media by category""" + url = f'/api/v1/search?c={self.category.title}' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Category search endpoint should return 200") + + # Check if media in the category are in the results + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media1.title, media_titles, "Media in category should be in results") + self.assertIn(self.media2.title, media_titles, "Media in category should be in results") + self.assertNotIn(self.media3.title, media_titles, "Media not in category should not be in results") + + def test_search_by_tag(self): + """Test searching media by tag""" + url = f'/api/v1/search?t={self.tag.title}' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Tag search endpoint should return 200") + + # Check if media with the tag are in the results + media_titles = [item['title'] for item in response.data['results']] + self.assertIn(self.media1.title, media_titles, "Media with tag should be in results") + self.assertIn(self.media2.title, media_titles, "Media with tag should be in results") + self.assertNotIn(self.media3.title, media_titles, "Media without tag should not be in results") + + def test_search_with_media_type_filter(self): + """Test searching with media type filter""" + url = '/api/v1/search?q=tutorial&media_type=video' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200, "Media type filtered search should return 200") + + # Create an image media with the same search term + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + image_media = Media.objects.create( + title="Tutorial Image", + description="Tutorial image description", + user=self.user, + media_file=myfile, + ) + image_media.update_search_vector() + + # Search with media_type=video + url = '/api/v1/search?q=tutorial&media_type=video' + response = self.client.get(url) + + media_titles = [item['title'] for item in response.data['results']] + self.assertNotIn(image_media.title, media_titles, "Image media should not be in results") diff --git a/tests/api/test_search_TOWRITE.py b/tests/api/test_search_TOWRITE.py deleted file mode 100644 index 2564e24e..00000000 --- a/tests/api/test_search_TOWRITE.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.test import TestCase - - -class TestX(TestCase): - fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] - - def test_X(self): - # add a few files, check search different cases that work - - pass diff --git a/tests/settings/test_portal_workflow.py b/tests/settings/test_portal_workflow.py new file mode 100644 index 00000000..f8b5f3a8 --- /dev/null +++ b/tests/settings/test_portal_workflow.py @@ -0,0 +1,97 @@ +from django.conf import settings +from django.core.files import File +from django.test import TestCase, override_settings + +from files.helpers import get_default_state, get_portal_workflow +from files.models import Media +from files.tests import create_account + + +class TestPortalWorkflow(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.user = create_account() + self.advanced_user = create_account(username="advanced_user") + self.advanced_user.advancedUser = True + self.advanced_user.save() + + def test_default_portal_workflow(self): + """Test the default portal workflow setting""" + workflow = get_portal_workflow() + self.assertEqual(workflow, settings.PORTAL_WORKFLOW, "get_portal_workflow should return the PORTAL_WORKFLOW setting") + + @override_settings(PORTAL_WORKFLOW='public') + def test_public_workflow(self): + """Test the public workflow setting""" + # Check that get_portal_workflow returns the correct value + self.assertEqual(get_portal_workflow(), 'public', "get_portal_workflow should return 'public'") + + # Check that get_default_state returns the correct value + self.assertEqual(get_default_state(), 'public', "get_default_state should return 'public'") + + # Check that a new media gets the correct state + # Mock the media_file requirement by patching the save method + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + media = Media.objects.create(title="Test Media", description="Test Description", user=self.user, media_file=myfile) + self.assertEqual(media.state, 'public', "Media state should be 'public' in public workflow") + + @override_settings(PORTAL_WORKFLOW='unlisted') + def test_unlisted_workflow(self): + """Test the unlisted workflow setting""" + # Check that get_portal_workflow returns the correct value + self.assertEqual(get_portal_workflow(), 'unlisted', "get_portal_workflow should return 'unlisted'") + + # Check that get_default_state returns the correct value + self.assertEqual(get_default_state(), 'unlisted', "get_default_state should return 'unlisted'") + + # Check that a new media gets the correct state + # Mock the media_file requirement by patching the save method + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + media = Media.objects.create(title="Test Media", description="Test Description", user=self.user, media_file=myfile) + self.assertEqual(media.state, 'unlisted', "Media state should be 'unlisted' in unlisted workflow") + + @override_settings(PORTAL_WORKFLOW='private') + def test_private_workflow(self): + """Test the private workflow setting""" + # Check that get_portal_workflow returns the correct value + self.assertEqual(get_portal_workflow(), 'private', "get_portal_workflow should return 'private'") + + # Check that get_default_state returns the correct value + self.assertEqual(get_default_state(), 'private', "get_default_state should return 'private'") + + # Check that a new media gets the correct state + # Mock the media_file requirement by patching the save method + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + media = Media.objects.create(title="Test Media", description="Test Description", user=self.user, media_file=myfile) + self.assertEqual(media.state, 'private', "Media state should be 'private' in private workflow") + + @override_settings(PORTAL_WORKFLOW='private_verified') + def test_private_verified_workflow(self): + """Test the private_verified workflow setting""" + # Check that get_portal_workflow returns the correct value + self.assertEqual(get_portal_workflow(), 'private_verified', "get_portal_workflow should return 'private_verified'") + + # Check that get_default_state returns the correct value for regular user + self.assertEqual(get_default_state(user=self.user), 'private', "get_default_state should return 'private' for regular user") + + # Check that get_default_state returns the correct value for advanced user + self.advanced_user.advancedUser = True + self.advanced_user.save() + self.assertEqual(get_default_state(user=self.advanced_user), 'unlisted', "get_default_state should return 'unlisted' for advanced user") + + # Check that a new media gets the correct state for regular user + # Mock the media_file requirement by patching the save method + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + media = Media.objects.create(title="Test Media", description="Test Description", user=self.user, media_file=myfile) + self.assertEqual(media.state, 'private', "Media state should be 'private' for regular user in private_verified workflow") + + # Check that a new media gets the correct state for advanced user + with open('fixtures/test_image2.jpg', "rb") as f: + myfile = File(f) + media = Media.objects.create(title="Advanced Test Media", description="Test Description", user=self.advanced_user, media_file=myfile) + self.assertEqual(media.state, 'unlisted', "Media state should be 'unlisted' for advanced user in private_verified workflow") diff --git a/tests/settings/test_portal_workflow_TOWRITE.py b/tests/settings/test_portal_workflow_TOWRITE.py deleted file mode 100644 index 179d282c..00000000 --- a/tests/settings/test_portal_workflow_TOWRITE.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.test import TestCase - - -class TestX(TestCase): - fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] - - def test_X(self): - # test what is the default portal workflow - # change it and make sure nothing strange happens (public/unlisted/private) - - pass diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 00000000..068bd8ee --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,51 @@ +from django.test import TestCase + + +class TestImports(TestCase): + """Test that all models and views can be imported correctly""" + + def test_model_imports(self): + """Test that all models can be imported""" + # Import all models + from files.models import Category # noqa: F401 + from files.models import Comment # noqa: F401 + from files.models import EncodeProfile # noqa: F401 + from files.models import Encoding # noqa: F401 + from files.models import Language # noqa: F401 + from files.models import License # noqa: F401 + from files.models import Media # noqa: F401 + from files.models import MediaPermission # noqa: F401 + from files.models import Playlist # noqa: F401 + from files.models import PlaylistMedia # noqa: F401 + from files.models import Rating # noqa: F401 + from files.models import RatingCategory # noqa: F401 + from files.models import Subtitle # noqa: F401 + from files.models import Tag # noqa: F401 + from files.models import VideoChapterData # noqa: F401 + from files.models import VideoTrimRequest # noqa: F401 + + # Simple assertion to verify imports worked + self.assertTrue(True, "All model imports succeeded") + + def test_view_imports(self): + """Test that all views can be imported""" + # Import all views + from files.views import CategoryList # noqa: F401 + from files.views import CommentDetail # noqa: F401 + from files.views import CommentList # noqa: F401 + from files.views import EncodeProfileList # noqa: F401 + from files.views import EncodingDetail # noqa: F401 + from files.views import MediaActions # noqa: F401 + from files.views import MediaBulkUserActions # noqa: F401 + from files.views import MediaDetail # noqa: F401 + from files.views import MediaList # noqa: F401 + from files.views import MediaSearch # noqa: F401 + from files.views import PlaylistDetail # noqa: F401 + from files.views import PlaylistList # noqa: F401 + from files.views import TagList # noqa: F401 + from files.views import TaskDetail # noqa: F401 + from files.views import TasksList # noqa: F401 + from files.views import UserActions # noqa: F401 + + # Simple assertion to verify imports worked + self.assertTrue(True, "All view imports succeeded") diff --git a/users/models.py b/users/models.py index b7966174..827021d8 100644 --- a/users/models.py +++ b/users/models.py @@ -11,7 +11,7 @@ from imagekit.models import ProcessedImageField from imagekit.processors import ResizeToFill import files.helpers as helpers -from files.models import Category, Media, Tag +from files.models import Category, Media, MediaPermission, Tag from rbac.models import RBACGroup @@ -123,7 +123,7 @@ class User(AbstractUser): Get all categories related to RBAC groups the user belongs to """ rbac_groups = RBACGroup.objects.filter(memberships__user=self, memberships__role__in=["member", "contributor", "manager"]) - categories = Category.objects.filter(rbac_groups__in=rbac_groups).distinct() + categories = Category.objects.prefetch_related("user").filter(rbac_groups__in=rbac_groups).distinct() return categories def has_member_access_to_category(self, category): @@ -131,8 +131,63 @@ class User(AbstractUser): return rbac_groups.exists() def has_member_access_to_media(self, media): - rbac_groups = RBACGroup.objects.filter(memberships__user=self, memberships__role__in=["member", "contributor", "manager"], categories__in=media.category.all()).distinct() - return rbac_groups.exists() + # First check if user is the owner + if media.user == self: + return True + + # Then check RBAC permissions + if getattr(settings, 'USE_RBAC', False): + rbac_groups = RBACGroup.objects.filter(memberships__user=self, memberships__role__in=["member", "contributor", "manager"], categories__in=media.category.all()).distinct() + if rbac_groups.exists(): + return True + + # Then check MediaShare permissions for any access + media_permission_exists = MediaPermission.objects.filter( + user=self, + media=media, + ).exists() + + return media_permission_exists + + def has_contributor_access_to_media(self, media): + # First check if user is the owner + if media.user == self: + return True + + # Then check RBAC permissions + if getattr(settings, 'USE_RBAC', False): + rbac_groups = RBACGroup.objects.filter(memberships__user=self, memberships__role__in=["contributor", "manager"], categories__in=media.category.all()).distinct() + if rbac_groups.exists(): + return True + + # Then check MediaShare permissions for editor or owner access + media_permission_exists = MediaPermission.objects.filter( + user=self, + media=media, + permission__in=["editor", "owner"], + ).exists() + + return media_permission_exists + + def has_owner_access_to_media(self, media): + # First check if user is the owner + if media.user == self: + return True + + # Then check RBAC permissions + if getattr(settings, 'USE_RBAC', False): + rbac_groups = RBACGroup.objects.filter(memberships__user=self, memberships__role__in=["manager"], categories__in=media.category.all()).distinct() + if rbac_groups.exists(): + return True + + # Then check MediaShare permissions for owner access + media_permission_exists = MediaPermission.objects.filter( + user=self, + media=media, + permission="owner", + ).exists() + + return media_permission_exists def get_rbac_categories_as_contributor(self): """ diff --git a/users/urls.py b/users/urls.py index c6648782..1b8767d9 100644 --- a/users/urls.py +++ b/users/urls.py @@ -5,11 +5,8 @@ from . import views urlpatterns = [ re_path(r"^user/(?P[\w@._-]*)$", views.view_user, name="get_user"), re_path(r"^user/(?P[\w@._-]*)/$", views.view_user, name="get_user"), - re_path( - r"^user/(?P[\w@.]*)/media$", - views.view_user_media, - name="get_user_media", - ), + re_path(r"^user/(?P[\w@._-]*)/shared_with_me", views.shared_with_me, name="shared_with_me"), + re_path(r"^user/(?P[\w@._-]*)/shared_by_me", views.shared_by_me, name="shared_by_me"), re_path( r"^user/(?P[\w@.]*)/playlists$", views.view_user_playlists, @@ -20,7 +17,7 @@ urlpatterns = [ views.view_user_about, name="get_user_about", ), - re_path(r"^user/(?P[\w@.]*)/edit$", views.edit_user, name="edit_user"), + re_path(r"^user/(?P[\w@._-]*)/edit$", views.edit_user, name="edit_user"), re_path(r"^channel/(?P[\w]*)$", views.view_channel, name="view_channel"), re_path( r"^channel/(?P[\w]*)/edit$", diff --git a/users/views.py b/users/views.py index acf14253..85cb4e7e 100644 --- a/users/views.py +++ b/users/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.mail import EmailMessage +from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import render from drf_yasg import openapi as openapi @@ -47,17 +48,28 @@ def view_user(request, username): return render(request, "cms/user.html", context) -def view_user_media(request, username): +def shared_with_me(request, username): context = {} user = get_user(username=username) - if not user: - return HttpResponseRedirect("/members") + if not user or (user != request.user): + return HttpResponseRedirect("/") context["user"] = user - context["CAN_EDIT"] = True if ((user and user == request.user) or is_mediacms_manager(request.user)) else False - context["CAN_DELETE"] = True if is_mediacms_manager(request.user) else False - context["SHOW_CONTACT_FORM"] = True if (user.allow_contact or is_mediacms_editor(request.user)) else False - return render(request, "cms/user_media.html", context) + context["CAN_EDIT"] = True + context["CAN_DELETE"] = True + return render(request, "cms/user_shared_with_me.html", context) + + +def shared_by_me(request, username): + context = {} + user = get_user(username=username) + if not user or (user != request.user): + return HttpResponseRedirect("/") + + context["user"] = user + context["CAN_EDIT"] = True + context["CAN_DELETE"] = True + return render(request, "cms/user_shared_by_me.html", context) def view_user_playlists(request, username): @@ -176,12 +188,17 @@ Sender email: %s\n class UserList(APIView): - permission_classes = (permissions.IsAuthenticatedOrReadOnly,) parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser) + def get_permissions(self): + if not settings.ALLOW_ANONYMOUS_USER_LISTING: + return [permissions.IsAuthenticated()] + return [permissions.IsAuthenticatedOrReadOnly()] + @swagger_auto_schema( manual_parameters=[ openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), + openapi.Parameter(name='name', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='Search by name or username'), ], tags=['Users'], operation_summary='List users', @@ -191,9 +208,10 @@ class UserList(APIView): pagination_class = api_settings.DEFAULT_PAGINATION_CLASS paginator = pagination_class() users = User.objects.filter() - location = request.GET.get("location", "").strip() - if location: - users = users.filter(location=location) + + name = request.GET.get("name", "").strip() + if name: + users = users.filter(Q(name__icontains=name) | Q(username__icontains=name)) page = paginator.paginate_queryset(users, request)