mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-08-20 01:18:31 +02:00
feat: bulk actions API
This commit is contained in:
2
Makefile
2
Makefile
@@ -1,7 +1,7 @@
|
|||||||
.PHONY: admin-shell build-frontend
|
.PHONY: admin-shell build-frontend
|
||||||
|
|
||||||
admin-shell:
|
admin-shell:
|
||||||
@container_id=$$(docker-compose ps -q web); \
|
@container_id=$$(docker compose ps -q web); \
|
||||||
if [ -z "$$container_id" ]; then \
|
if [ -z "$$container_id" ]; then \
|
||||||
echo "Web container not found"; \
|
echo "Web container not found"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
|
@@ -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
|
* [Configuration](docs/admins_docs.md#5-configuration) page
|
||||||
* [Transcoding](docs/transcoding.md) page
|
* [Transcoding](docs/transcoding.md) page
|
||||||
* [Developer Experience](docs/dev_exp.md) page
|
* [Developer Experience](docs/dev_exp.md) page
|
||||||
|
* [Media Permissions](docs/media_permissions.md) page
|
||||||
|
|
||||||
|
|
||||||
## Technology
|
## Technology
|
||||||
|
@@ -498,6 +498,9 @@ ALLOW_VIDEO_TRIMMER = True
|
|||||||
|
|
||||||
ALLOW_CUSTOM_MEDIA_URLS = False
|
ALLOW_CUSTOM_MEDIA_URLS = False
|
||||||
|
|
||||||
|
# Whether to allow anonymous users to list all users
|
||||||
|
ALLOW_ANONYMOUS_USER_LISTING = True
|
||||||
|
|
||||||
# ffmpeg options
|
# ffmpeg options
|
||||||
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
|
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||||
|
|
||||||
|
@@ -1 +1 @@
|
|||||||
VERSION = "6.3.0"
|
VERSION = "6.4.0"
|
||||||
|
@@ -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.
|
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`
|
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
|
### 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.
|
- **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
|
## 6. Manage pages
|
||||||
to be written
|
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
|
## 25. Custom urls
|
||||||
To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py
|
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.
|
||||||
|
@@ -4,10 +4,10 @@ There is ongoing effort to provide a better developer experience and document it
|
|||||||
## How to develop locally with Docker
|
## 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/).
|
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
|
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
|
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
|
### Development workflow with the frontend
|
||||||
1. Edit frontend/src/ files
|
1. Edit frontend/src/ files
|
||||||
2. Check changes on http://localhost:8088/
|
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/`
|
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
|
6. Commit the changes
|
||||||
|
|
||||||
### Helper commands
|
### Helper commands
|
||||||
@@ -81,7 +81,7 @@ Build the frontend:
|
|||||||
|
|
||||||
```
|
```
|
||||||
user@user:~/mediacms$ make build-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-frontend@0.9.1 dist /home/mediacms.io/mediacms/frontend
|
||||||
> mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist
|
> mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist
|
||||||
|
@@ -17,7 +17,7 @@ to be written
|
|||||||
|
|
||||||
## 3. API documentation
|
## 3. API documentation
|
||||||
API is documented using Swagger - checkout ot http://your_installation/swagger - example https://demo.mediacms.io/swagger/
|
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:
|
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)
|
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 build
|
||||||
docker-compose -f docker-compose-dev.yaml up
|
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`:
|
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
|
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/
|
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
|
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
|
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
|
## 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
|
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
|
## 6. Working with the automated tests
|
||||||
|
|
||||||
@@ -122,19 +122,19 @@ This instructions assume that you're using the docker installation
|
|||||||
1. start docker-compose
|
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)
|
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
|
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)
|
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
|
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
|
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 ;)
|
and of course...you are very welcome to help us increase it ;)
|
||||||
|
166
docs/media_permissions.md
Normal file
166
docs/media_permissions.md
Normal file
@@ -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.
|
@@ -68,14 +68,18 @@ class MediaMetadataForm(forms.ModelForm):
|
|||||||
self.helper.form_method = 'post'
|
self.helper.form_method = 'post'
|
||||||
self.helper.form_enctype = "multipart/form-data"
|
self.helper.form_enctype = "multipart/form-data"
|
||||||
self.helper.form_show_errors = False
|
self.helper.form_show_errors = False
|
||||||
self.helper.layout = Layout(
|
|
||||||
|
layout_fields = [
|
||||||
CustomField('title'),
|
CustomField('title'),
|
||||||
CustomField('new_tags'),
|
CustomField('new_tags'),
|
||||||
CustomField('add_date'),
|
CustomField('add_date'),
|
||||||
CustomField('description'),
|
CustomField('description'),
|
||||||
CustomField('uploaded_poster'),
|
|
||||||
CustomField('enable_comments'),
|
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":
|
if self.instance.media_type == "video":
|
||||||
self.helper.layout.append(CustomField('thumbnail_time'))
|
self.helper.layout.append(CustomField('thumbnail_time'))
|
||||||
|
@@ -567,3 +567,42 @@ def handle_video_chapters(media, chapters):
|
|||||||
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
||||||
|
|
||||||
return media.chapter_data
|
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
|
||||||
|
29
files/migrations/0011_mediapermission.py
Normal file
29
files/migrations/0011_mediapermission.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
25
files/models/__init__.py
Normal file
25
files/models/__init__.py
Normal file
@@ -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
|
156
files/models/category.py
Normal file
156
files/models/category.py
Normal file
@@ -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
|
46
files/models/comment.py
Normal file
46
files/models/comment.py
Normal file
@@ -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()
|
303
files/models/encoding.py
Normal file
303
files/models/encoding.py
Normal file
@@ -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
|
11
files/models/license.py
Normal file
11
files/models/license.py
Normal file
@@ -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
|
@@ -3,15 +3,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import m3u8
|
import m3u8
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.contrib.postgres.search import SearchVectorField
|
from django.contrib.postgres.search import SearchVectorField
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Func, Value
|
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.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
from imagekit.models import ProcessedImageField
|
from imagekit.models import ProcessedImageField
|
||||||
from imagekit.processors import ResizeToFit
|
from imagekit.processors import ResizeToFit
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
|
||||||
|
|
||||||
from . import helpers
|
from .. import helpers
|
||||||
from .stop_words import STOP_WORDS
|
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__)
|
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):
|
class Media(models.Model):
|
||||||
"""The most important model for MediaCMS"""
|
"""The most important model for MediaCMS"""
|
||||||
@@ -138,7 +52,6 @@ class Media(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
help_text="Media can exist in one or no Channels",
|
help_text="Media can exist in one or no Channels",
|
||||||
)
|
)
|
||||||
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
dislikes = models.IntegerField(default=0)
|
dislikes = models.IntegerField(default=0)
|
||||||
@@ -566,7 +479,7 @@ class Media(models.Model):
|
|||||||
To be used on the video player
|
To be used on the video player
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import tasks
|
from .. import tasks
|
||||||
|
|
||||||
tasks.produce_sprite_from_video.delay(self.friendly_token)
|
tasks.produce_sprite_from_video.delay(self.friendly_token)
|
||||||
return True
|
return True
|
||||||
@@ -582,7 +495,7 @@ class Media(models.Model):
|
|||||||
profiles = EncodeProfile.objects.filter(active=True)
|
profiles = EncodeProfile.objects.filter(active=True)
|
||||||
profiles = list(profiles)
|
profiles = list(profiles)
|
||||||
|
|
||||||
from . import tasks
|
from .. import tasks
|
||||||
|
|
||||||
# attempt to break media file in chunks
|
# attempt to break media file in chunks
|
||||||
if self.duration > settings.CHUNKIZE_VIDEO_DURATION and chunkize:
|
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"])
|
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:
|
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)
|
tasks.create_hls.delay(self.friendly_token)
|
||||||
|
|
||||||
@@ -993,585 +906,26 @@ class Media(models.Model):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class License(models.Model):
|
class MediaPermission(models.Model):
|
||||||
"""A Base license model to be used in Media"""
|
"""Model to store user permissions for media"""
|
||||||
|
|
||||||
title = models.CharField(max_length=100, unique=True)
|
PERMISSION_CHOICES = (
|
||||||
description = models.TextField(blank=True)
|
("viewer", "Viewer"),
|
||||||
|
("editor", "Editor"),
|
||||||
def __str__(self):
|
("owner", "Owner"),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
|
owner_user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='granted_permissions')
|
||||||
|
user = models.ForeignKey('users.User', on_delete=models.CASCADE)
|
||||||
is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups')
|
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='permissions')
|
||||||
|
permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES)
|
||||||
identity_provider = models.ForeignKey(
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
'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:
|
class Meta:
|
||||||
ordering = ["title"]
|
unique_together = ('user', 'media')
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return f"{self.user.username} - {self.media.title} ({self.permission})"
|
||||||
|
|
||||||
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})"
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Media)
|
@receiver(post_save, sender=Media)
|
||||||
@@ -1585,7 +939,7 @@ def media_save(sender, instance, created, **kwargs):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
from .methods import notify_users
|
from ..methods import notify_users
|
||||||
|
|
||||||
instance.media_init()
|
instance.media_init()
|
||||||
notify_users(friendly_token=instance.friendly_token, action="media_added")
|
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()
|
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)
|
@receiver(post_delete, sender=Media)
|
||||||
def media_file_delete(sender, instance, **kwargs):
|
def media_file_delete(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -1661,166 +1010,3 @@ def media_m2m(sender, instance, **kwargs):
|
|||||||
if instance.tags.all():
|
if instance.tags.all():
|
||||||
for tag in instance.tags.all():
|
for tag in instance.tags.all():
|
||||||
tag.update_tag_media()
|
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
|
|
97
files/models/playlist.py
Normal file
97
files/models/playlist.py
Normal file
@@ -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"]
|
47
files/models/rating.py
Normal file
47
files/models/rating.py
Normal file
@@ -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}"
|
72
files/models/subtitle.py
Normal file
72
files/models/subtitle.py
Normal file
@@ -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
|
99
files/models/utils.py
Normal file
99
files/models/utils.py
Normal file
@@ -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")
|
86
files/models/video_data.py
Normal file
86
files/models/video_data.py
Normal file
@@ -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)
|
@@ -820,7 +820,7 @@ def update_listings_thumbnails():
|
|||||||
# Categories
|
# Categories
|
||||||
used_media = []
|
used_media = []
|
||||||
saved = 0
|
saved = 0
|
||||||
qs = Category.objects.filter().order_by("-media_count")
|
qs = Category.objects.filter()
|
||||||
for object in qs:
|
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()
|
media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first()
|
||||||
if media:
|
if media:
|
||||||
@@ -833,7 +833,7 @@ def update_listings_thumbnails():
|
|||||||
# Tags
|
# Tags
|
||||||
used_media = []
|
used_media = []
|
||||||
saved = 0
|
saved = 0
|
||||||
qs = Tag.objects.filter().order_by("-media_count")
|
qs = Tag.objects.filter()
|
||||||
for object in qs:
|
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()
|
media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first()
|
||||||
if media:
|
if media:
|
||||||
|
@@ -48,6 +48,8 @@ urlpatterns = [
|
|||||||
re_path(r"^view", views.view_media, name="get_media"),
|
re_path(r"^view", views.view_media, name="get_media"),
|
||||||
re_path(r"^upload", views.upload_media, name="upload_media"),
|
re_path(r"^upload", views.upload_media, name="upload_media"),
|
||||||
# API VIEWS
|
# 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(r"^api/v1/media/$", views.MediaList.as_view()),
|
re_path(r"^api/v1/media/$", views.MediaList.as_view()),
|
||||||
re_path(
|
re_path(
|
||||||
|
1744
files/views.py
1744
files/views.py
File diff suppressed because it is too large
Load Diff
43
files/views/__init__.py
Normal file
43
files/views/__init__.py
Normal file
@@ -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
|
42
files/views/auth.py
Normal file
42
files/views/auth.py
Normal file
@@ -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 = ['<?xml version="1.0"?>']
|
||||||
|
saml_social_apps = SocialApp.objects.filter(provider='saml')
|
||||||
|
entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
|
||||||
|
xml_parts.append(f'<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" Name="{entity_id}">') # noqa
|
||||||
|
xml_parts.append(f' <md:EntityDescriptor entityID="{entity_id}">') # noqa
|
||||||
|
xml_parts.append(' <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">') # noqa
|
||||||
|
|
||||||
|
# Add multiple AssertionConsumerService elements with different indices
|
||||||
|
for index, app in enumerate(saml_social_apps, start=1):
|
||||||
|
xml_parts.append(
|
||||||
|
f' <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" ' # noqa
|
||||||
|
f'Location="{settings.FRONTEND_HOST}/accounts/saml/{app.client_id}/acs/" index="{index}"/>' # noqa
|
||||||
|
)
|
||||||
|
|
||||||
|
xml_parts.append(' </md:SPSSODescriptor>') # noqa
|
||||||
|
xml_parts.append(' </md:EntityDescriptor>') # noqa
|
||||||
|
xml_parts.append('</md:EntitiesDescriptor>') # 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})
|
66
files/views/categories.py
Normal file
66
files/views/categories.py
Normal file
@@ -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)
|
159
files/views/comments.py
Normal file
159
files/views/comments.py
Normal file
@@ -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)
|
179
files/views/encoding.py
Normal file
179
files/views/encoding.py
Normal file
@@ -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)
|
763
files/views/media.py
Normal file
763
files/views/media.py
Normal file
@@ -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)
|
593
files/views/pages.py
Normal file
593
files/views/pages.py
Normal file
@@ -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)
|
195
files/views/playlists.py
Normal file
195
files/views/playlists.py
Normal file
@@ -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)
|
30
files/views/tasks.py
Normal file
30
files/views/tasks.py
Normal file
@@ -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)
|
45
files/views/user.py
Normal file
45
files/views/user.py
Normal file
@@ -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)
|
BIN
fixtures/test_image2.jpg
Normal file
BIN
fixtures/test_image2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
@@ -19,4 +19,4 @@
|
|||||||
{% include "components/footer.html" %}
|
{% include "components/footer.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}<script src="{% static "js/base.js" %}"></script>{% endblock bottomimports %}
|
{% block bottomimports %}<script src="{% static "js/base.js" %}?v={{ VERSION }}"></script>{% endblock bottomimports %}
|
@@ -129,7 +129,7 @@
|
|||||||
{% endblock innercontent %}
|
{% endblock innercontent %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/add-media.js" %}"></script>
|
<script src="{% static "js/add-media.js" %}?v={{ VERSION }}"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function(event) {
|
document.addEventListener("DOMContentLoaded", function(event) {
|
||||||
function getCSRFToken() {
|
function getCSRFToken() {
|
||||||
|
@@ -41,5 +41,5 @@
|
|||||||
{% block content %}<div id="page-categories"></div>{% endblock %}
|
{% block content %}<div id="page-categories"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/categories.js" %}"></script>
|
<script src="{% static "js/categories.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -9,5 +9,5 @@
|
|||||||
{% block content %}<div id="page-embed"></div>{% endblock content %}
|
{% block content %}<div id="page-embed"></div>{% endblock content %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/embed.js" %}"></script>
|
<script src="{% static "js/embed.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -43,5 +43,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/featured.js" %}"></script>
|
<script src="{% static "js/featured.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -13,5 +13,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/history.js" %}"></script>
|
<script src="{% static "js/history.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -48,5 +48,5 @@
|
|||||||
{% block content %}<div id="page-home"></div>{% endblock %}
|
{% block content %}<div id="page-home"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/index.js" %}"></script>
|
<script src="{% static "js/index.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -43,5 +43,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/latest.js" %}"></script>
|
<script src="{% static "js/latest.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -13,5 +13,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/liked.js" %}"></script>
|
<script src="{% static "js/liked.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -14,5 +14,5 @@
|
|||||||
{% block content %}<div id="page-manage-comments"></div>{% endblock %}
|
{% block content %}<div id="page-manage-comments"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/manage-comments.js" %}"></script>
|
<script src="{% static "js/manage-comments.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -19,6 +19,6 @@ window.CATEGORIES = {{ categories|safe }};
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/manage-media.js" %}"></script>
|
<script src="{% static "js/manage-media.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
|
||||||
|
@@ -14,5 +14,5 @@
|
|||||||
{% block content %}<div id="page-manage-users"></div>{% endblock %}
|
{% block content %}<div id="page-manage-users"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/manage-users.js" %}"></script>
|
<script src="{% static "js/manage-users.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -129,5 +129,5 @@
|
|||||||
{% block content %}<div id="page-media"></div>{% endblock content %}
|
{% block content %}<div id="page-media"></div>{% endblock content %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/media.js" %}"></script>
|
<script src="{% static "js/media.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -41,5 +41,5 @@
|
|||||||
{% block content %}<div id="page-members"></div>{% endblock %}
|
{% block content %}<div id="page-members"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/members.js" %}"></script>
|
<script src="{% static "js/members.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -13,5 +13,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/playlist.js" %}"></script>
|
<script src="{% static "js/playlist.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -43,5 +43,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/recommended.js" %}"></script>
|
<script src="{% static "js/recommended.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/search.js" %}"></script>
|
<script src="{% static "js/search.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -41,5 +41,5 @@
|
|||||||
{% block content %}<div id="page-tags"></div>{% endblock %}
|
{% block content %}<div id="page-tags"></div>{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/tags.js" %}"></script>
|
<script src="{% static "js/tags.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -29,5 +29,5 @@ No such user
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/profile-media.js" %}"></script>
|
<script src="{% static "js/profile-media.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
@@ -29,5 +29,5 @@ No such user
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/profile-about.js" %}"></script>
|
<script src="{% static "js/profile-about.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
@@ -29,5 +29,5 @@ No such user
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bottomimports %}
|
{% block bottomimports %}
|
||||||
<script src="{% static "js/profile-playlists.js" %}"></script>
|
<script src="{% static "js/profile-playlists.js" %}?v={{ VERSION }}"></script>
|
||||||
{% endblock bottomimports %}
|
{% endblock bottomimports %}
|
||||||
|
37
templates/cms/user_shared_by_me.html
Normal file
37
templates/cms/user_shared_by_me.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block headtitle %}{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}{% endblock headtitle %}
|
||||||
|
|
||||||
|
{% block headermeta %}
|
||||||
|
|
||||||
|
<meta property="og:title" content="{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:description" content="">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
|
||||||
|
{% endblock headermeta %}
|
||||||
|
|
||||||
|
{% block topimports %}
|
||||||
|
{% load static %}
|
||||||
|
<link href="{% static "css/profile-media.css" %}?v={{ VERSION }}" rel="preload" as="style">
|
||||||
|
<link href="{% static "css/profile-media.css" %}?v={{ VERSION }}" rel="stylesheet">
|
||||||
|
{%endblock topimports %}
|
||||||
|
|
||||||
|
{% block innercontent %}
|
||||||
|
{% if user %}{% else %}
|
||||||
|
No such user
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if user %}
|
||||||
|
<div id="page-profile-media"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bottomimports %}
|
||||||
|
<script src="{% static "js/profile-media.js" %}?v={{ VERSION }}"></script>
|
||||||
|
{% endblock bottomimports %}
|
34
templates/cms/user_shared_with_me.html
Normal file
34
templates/cms/user_shared_with_me.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block headtitle %}{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}{% endblock headtitle %}
|
||||||
|
|
||||||
|
{% block headermeta %}
|
||||||
|
|
||||||
|
<meta property="og:title" content="{% if user.name %}{{user.name}} - {% endif %}{{PORTAL_NAME}}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:description" content="">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
|
||||||
|
{% endblock headermeta %}
|
||||||
|
|
||||||
|
{% block topimports %}
|
||||||
|
{% load static %}
|
||||||
|
<link href="{% static "css/profile-media.css" %}?v={{ VERSION }}" rel="preload" as="style">
|
||||||
|
<link href="{% static "css/profile-media.css" %}?v={{ VERSION }}" rel="stylesheet">
|
||||||
|
{%endblock topimports %}
|
||||||
|
|
||||||
|
{% block innercontent %}
|
||||||
|
{% if user %}{% else %}
|
||||||
|
No such user
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if user %}<div id="page-profile-media"></div>{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bottomimports %}
|
||||||
|
<script src="{% static "js/profile-media.js" %}?v={{ VERSION }}"></script>
|
||||||
|
{% endblock bottomimports %}
|
67
tests/api/test_media_listings.py
Normal file
67
tests/api/test_media_listings.py
Normal file
@@ -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")
|
@@ -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
|
|
114
tests/api/test_search.py
Normal file
114
tests/api/test_search.py
Normal file
@@ -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")
|
@@ -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
|
|
97
tests/settings/test_portal_workflow.py
Normal file
97
tests/settings/test_portal_workflow.py
Normal file
@@ -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")
|
@@ -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
|
|
51
tests/test_imports.py
Normal file
51
tests/test_imports.py
Normal file
@@ -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")
|
@@ -11,7 +11,7 @@ from imagekit.models import ProcessedImageField
|
|||||||
from imagekit.processors import ResizeToFill
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
import files.helpers as helpers
|
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
|
from rbac.models import RBACGroup
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ class User(AbstractUser):
|
|||||||
Get all categories related to RBAC groups the user belongs to
|
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"])
|
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
|
return categories
|
||||||
|
|
||||||
def has_member_access_to_category(self, category):
|
def has_member_access_to_category(self, category):
|
||||||
@@ -131,8 +131,63 @@ class User(AbstractUser):
|
|||||||
return rbac_groups.exists()
|
return rbac_groups.exists()
|
||||||
|
|
||||||
def has_member_access_to_media(self, media):
|
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()
|
# First check if user is the owner
|
||||||
return rbac_groups.exists()
|
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):
|
def get_rbac_categories_as_contributor(self):
|
||||||
"""
|
"""
|
||||||
|
@@ -5,11 +5,8 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r"^user/(?P<username>[\w@._-]*)$", views.view_user, name="get_user"),
|
re_path(r"^user/(?P<username>[\w@._-]*)$", views.view_user, name="get_user"),
|
||||||
re_path(r"^user/(?P<username>[\w@._-]*)/$", views.view_user, name="get_user"),
|
re_path(r"^user/(?P<username>[\w@._-]*)/$", views.view_user, name="get_user"),
|
||||||
re_path(
|
re_path(r"^user/(?P<username>[\w@._-]*)/shared_with_me", views.shared_with_me, name="shared_with_me"),
|
||||||
r"^user/(?P<username>[\w@.]*)/media$",
|
re_path(r"^user/(?P<username>[\w@._-]*)/shared_by_me", views.shared_by_me, name="shared_by_me"),
|
||||||
views.view_user_media,
|
|
||||||
name="get_user_media",
|
|
||||||
),
|
|
||||||
re_path(
|
re_path(
|
||||||
r"^user/(?P<username>[\w@.]*)/playlists$",
|
r"^user/(?P<username>[\w@.]*)/playlists$",
|
||||||
views.view_user_playlists,
|
views.view_user_playlists,
|
||||||
@@ -20,7 +17,7 @@ urlpatterns = [
|
|||||||
views.view_user_about,
|
views.view_user_about,
|
||||||
name="get_user_about",
|
name="get_user_about",
|
||||||
),
|
),
|
||||||
re_path(r"^user/(?P<username>[\w@.]*)/edit$", views.edit_user, name="edit_user"),
|
re_path(r"^user/(?P<username>[\w@._-]*)/edit$", views.edit_user, name="edit_user"),
|
||||||
re_path(r"^channel/(?P<friendly_token>[\w]*)$", views.view_channel, name="view_channel"),
|
re_path(r"^channel/(?P<friendly_token>[\w]*)$", views.view_channel, name="view_channel"),
|
||||||
re_path(
|
re_path(
|
||||||
r"^channel/(?P<friendly_token>[\w]*)/edit$",
|
r"^channel/(?P<friendly_token>[\w]*)/edit$",
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from drf_yasg import openapi as openapi
|
from drf_yasg import openapi as openapi
|
||||||
@@ -47,17 +48,28 @@ def view_user(request, username):
|
|||||||
return render(request, "cms/user.html", context)
|
return render(request, "cms/user.html", context)
|
||||||
|
|
||||||
|
|
||||||
def view_user_media(request, username):
|
def shared_with_me(request, username):
|
||||||
context = {}
|
context = {}
|
||||||
user = get_user(username=username)
|
user = get_user(username=username)
|
||||||
if not user:
|
if not user or (user != request.user):
|
||||||
return HttpResponseRedirect("/members")
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
context["user"] = user
|
context["user"] = user
|
||||||
context["CAN_EDIT"] = True if ((user and user == request.user) or is_mediacms_manager(request.user)) else False
|
context["CAN_EDIT"] = True
|
||||||
context["CAN_DELETE"] = True if is_mediacms_manager(request.user) else False
|
context["CAN_DELETE"] = True
|
||||||
context["SHOW_CONTACT_FORM"] = True if (user.allow_contact or is_mediacms_editor(request.user)) else False
|
return render(request, "cms/user_shared_with_me.html", context)
|
||||||
return render(request, "cms/user_media.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):
|
def view_user_playlists(request, username):
|
||||||
@@ -176,12 +188,17 @@ Sender email: %s\n
|
|||||||
|
|
||||||
|
|
||||||
class UserList(APIView):
|
class UserList(APIView):
|
||||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
|
||||||
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
|
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(
|
@swagger_auto_schema(
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
|
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'],
|
tags=['Users'],
|
||||||
operation_summary='List users',
|
operation_summary='List users',
|
||||||
@@ -191,9 +208,10 @@ class UserList(APIView):
|
|||||||
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
|
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
|
||||||
paginator = pagination_class()
|
paginator = pagination_class()
|
||||||
users = User.objects.filter()
|
users = User.objects.filter()
|
||||||
location = request.GET.get("location", "").strip()
|
|
||||||
if location:
|
name = request.GET.get("name", "").strip()
|
||||||
users = users.filter(location=location)
|
if name:
|
||||||
|
users = users.filter(Q(name__icontains=name) | Q(username__icontains=name))
|
||||||
|
|
||||||
page = paginator.paginate_queryset(users, request)
|
page = paginator.paginate_queryset(users, request)
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user