Compare commits

...

6 Commits

Author SHA1 Message Date
Markos Gogoulos
cbef629baf feat: approve users, edit users through manage users page (#1383) 2025-09-20 15:16:52 +03:00
Bret.S (AKA: CyberGladius)
8e8454d8c2 Docs Update with SAML Deployement Guide and Troubleshooting (#1377)
* Docs update with SAML deployment guide.

* Docs update with SAML deployment guide. URL Fix

---------

Co-authored-by: root <git@tdcme.com>
2025-09-16 14:51:05 +03:00
Markos Gogoulos
8d982ace92 Feat whisper opts (#1368) 2025-09-04 13:39:41 +03:00
Meet Dholakia
6cee02085c Updated the title splicing length to 100 in playlist, category and media models (#1364) 2025-09-02 19:43:46 +03:00
Markos Gogoulos
e33aa17911 fix version 2025-09-02 11:32:02 +03:00
Markos Gogoulos
a8db23f204 docs: add whisper section 2025-09-02 11:00:17 +03:00
65 changed files with 1810 additions and 851 deletions

View File

@@ -59,6 +59,15 @@ jobs:
org.opencontainers.image.source=https://github.com/mediacms-io/mediacms
org.opencontainers.image.licenses=AGPL-3.0
- name: Build and push full image
uses: docker/build-push-action@v4
with:
context: .
target: full
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-full.outputs.tags }}
labels: ${{ steps.meta-full.outputs.labels }}
- name: Build and push base image
uses: docker/build-push-action@v4
with:
@@ -67,12 +76,3 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-base.outputs.tags }}
labels: ${{ steps.meta-base.outputs.labels }}
- name: Build and push full image
uses: docker/build-push-action@v4
with:
context: .
target: full
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-full.outputs.tags }}
labels: ${{ steps.meta-full.outputs.labels }}

View File

@@ -25,11 +25,12 @@ A demo is available at https://demo.mediacms.io
- **Complete control over your data**: host it yourself!
- **Modern technologies**: Django/Python/Celery, React.
- **Support for multiple publishing workflows**: public, private, unlisted and custom
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
- **Automatic transcription**: through integration with Whisper running locally
- **Multiple media types support**: video, audio, image, pdf
- **Multiple media classification options**: categories, tags and custom
- **Multiple media sharing options**: social media share, videos embed code generation
- **Video Trimmer**: trim video, replace, save as new or create segments
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
- **SAML support**: with ability to add mappings to system roles and groups
- **Easy media searching**: enriched with live search functionality
- **Playlists for audio and video content**: create playlists, add and reorder content
@@ -48,7 +49,7 @@ A demo is available at https://demo.mediacms.io
## Example cases
- **Schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
- **Universities, schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
- **Organization sensitive content.** In cases where content is sensitive and cannot be uploaded to external sites.
- **Build a great community.** MediaCMS can be customized (URLs, logos, fonts, aesthetics) so that you create a highly customized video portal for your community!
- **Personal portal.** Organize, categorize and host your content the way you prefer.
@@ -83,6 +84,7 @@ For a small to medium installation, with a few hours of video uploaded daily, an
In terms of disk space, think of what the needs will be. A general rule is to multiply by three the size of the expected uploaded videos (since the system keeps original versions, encoded versions plus HLS), so if you receive 1G of videos daily and maintain all of them, you should consider a 1T disk across a year (1G * 3 * 365).
In order to support automatic transcriptions through Whisper, consider more CPUs.
## Installation / Maintanance
@@ -110,7 +112,7 @@ This software uses the following list of awesome technologies: Python, Django, D
## Who is using it
- **Multiple Universities** for hosting educational videos
- **Cinemata** non-profit media, technology and culture organization - https://cinemata.org
- **Critical Commons** public media archive and fair use advocacy network - https://criticalcommons.org
- **American Association of Gynecologic Laparoscopists** - https://surgeryu.org/

10
cms/auth_backends.py Normal file
View File

@@ -0,0 +1,10 @@
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
class ApprovalBackend(ModelBackend):
def user_can_authenticate(self, user):
can_authenticate = super().user_can_authenticate(user)
if can_authenticate and settings.USERS_NEEDS_TO_BE_APPROVED and not user.is_superuser:
return getattr(user, 'is_approved', False)
return can_authenticate

23
cms/middleware.py Normal file
View File

@@ -0,0 +1,23 @@
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
class ApprovalMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if settings.USERS_NEEDS_TO_BE_APPROVED and request.user.is_authenticated and not request.user.is_superuser and not getattr(request.user, 'is_approved', False):
allowed_paths = [
reverse('approval_required'),
reverse('account_logout'),
]
if request.path not in allowed_paths:
if request.path.startswith('/api/'):
return JsonResponse({'detail': 'User account not approved.'}, status=403)
return redirect('approval_required')
response = self.get_response(request)
return response

View File

@@ -128,6 +128,10 @@ USERS_CAN_SELF_REGISTER = True
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
# by default users do not need to be approved. If this is set to True, then new users
# will have to be approved before they can login successfully
USERS_NEEDS_TO_BE_APPROVED = False
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
# Empty list disables.
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
@@ -501,6 +505,10 @@ ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page
# valid choices are all, editors, admins
CAN_SEE_MEMBERS_PAGE = "all"
# Maximum number of media a user can upload
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100
@@ -517,6 +525,9 @@ USER_CAN_TRANSCRIBE_VIDEO = True
# Whisper transcribe options - https://github.com/openai/whisper
WHISPER_MODEL = "base"
# show a custom text in the sidebar footer, otherwise the default will be shown if this is empty
SIDEBAR_FOOTER_TEXT = ""
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
@@ -558,3 +569,12 @@ except ImportError:
if GLOBAL_LOGIN_REQUIRED:
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
if USERS_NEEDS_TO_BE_APPROVED:
AUTHENTICATION_BACKENDS = (
'cms.auth_backends.ApprovalBackend',
'allauth.account.auth_backends.AuthenticationBackend',
)
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")

View File

@@ -1 +1 @@
VERSION = "6.5.1"
VERSION = "6.6.0"

View File

@@ -28,6 +28,7 @@
- [25. Custom urls](#25-custom-urls)
- [26. Allowed files](#26-allowed-files)
- [27. User upload limits](#27-user-upload-limits)
- [28. Whisper Transcribe for Automatic Subtitles](#28-whisper-transcribe-for-automatic-subtitles)
## 1. Welcome
@@ -518,6 +519,20 @@ ALLOW_ANONYMOUS_USER_LISTING = False
When set to False, only logged-in users will be able to access the user listing API endpoint.
### 5.27 Control who can see the members page
By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can see the members page. Other valid options are:
- **editors**, only MediaCMS editors can view the page
- **admins**, only MediaCMS admins can view the page
### 5.28 Require user approval on registration
By default, users do not require approval, so they can login immediately after registration (if registration is open). However, if the parameter `USERS_NEEDS_TO_BE_APPROVED` is set to `True`, they will first have to have their accounts approved by an administrator before they can successfully sign in.
Administrators can approve users through the following ways: 1. through Django administration, 2. through the users management page, 3. through editing the profile page directly. In all cases, set 'Is approved' to True.
## 6. Manage pages
to be written
@@ -962,6 +977,8 @@ Select the SAML Configurations tab, create a new one and set:
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
A full SAML deployment with [EntraID guide and troubleshooting steps is available here.](./saml_entraid_setup.md). This guide can be used as reference for other IDPs too.
## 24. Identity Providers setup
A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options:

315
docs/saml_entraid_setup.md Normal file
View File

@@ -0,0 +1,315 @@
# Integrating Microsoft Entra ID (formerly Azure AD) with MediaCMS via SAML Authentication
This guide provides step-by-step instructions on how to configure Microsoft Entra ID as a SAML Identity Provider (IdP) for MediaCMS, an open-source content management system. The goal is to enable single sign-on (SSO) authentication for users in a secure and scalable way.
## Table of Contents
1. [Overview](#overview)
2. [Prerequisites](#prerequisites)
3. [Step 1: Configure MediaCMS for SAML](#step-1-configure-mediacms-for-saml)
4. [Step 2: Register MediaCMS as an Enterprise App in Entra ID](#step-2-register-mediacms-as-an-enterprise-app-in-entra-id)
5. [Step 3: Configure SAML Settings in Entra ID](#step-3-configure-saml-settings-in-entra-id)
6. [Step 4: Configure SAML Settings in MediaCMS](#step-4-configure-saml-settings-in-mediacms)
7. [Step 5: Allow Users or Groups to Log Into the Application](#step-5-allow-users-or-groups-to-log-into-the-application)
8. [Step 6: Test and Validate Login Flow](#step-6-test-and-validate-login-flow)
9. [Troubleshooting](#troubleshooting)
10. [Resources](#resources)
---
## Overview
MediaCMS supports SAML 2.0 authentication by acting as a Service Provider (SP). By integrating with Microsoft Entra ID, organizations can allow users to authenticate using their existing enterprise credentials.
In our particular deployment of MediaCMS, the application is hosted internally with no direct inbound access from the public Internet. As an internal company application, it was essential to integrate it with our existing authentication systems and provide a seamless single sign-on experience. This is where the SAML protocol shines.
One of the major advantages of SAML authentication is that all communication between the Identity Provider (IdP) — in this case, Microsoft Entra ID — and the Service Provider (SP) — MediaCMS — is brokered entirely by the end user's browser. The browser initiates the authentication flow, communicates securely with Microsofts login portal, receives the identity assertion, and then passes it back to the internal MediaCMS server.
This architecture enables the MediaCMS server to remain isolated from the Internet while still participating in a modern and seamless federated login experience.
Even though the deployment method outlined in this tutorial is for EntraID on an isolated MediaCMS server, the same steps and general information could be applied to another authentication SAML provider/identity provider on a non-isolated system.
> **Note**: This guide assumes you are running MediaCMS with Django backend and that the `django-allauth` library is enabled and configured.
---
## Prerequisites
Before beginning, ensure the following:
* You have administrator access to both MediaCMS and Microsoft Entra ID (Azure portal).
* MediaCMS is installed and accessible via HTTPS, with a valid SSL certificate.
* Your MediaCMS installation has SAML support enabled (via `django-allauth`).
* You have a dedicated domain or subdomain for MediaCMS (e.g., `https://<MyMediaCMS.MyDomain.com>`).
---
## Step 1: Configure MediaCMS for SAML
The first step in enabling SAML authentication is to modify the `local_settings.py` (for Docker: `./deploy/docker/local_settings.py`) file of your MediaCMS deployment. Add the following configuration block to enable SAML support, role-based access control (RBAC), and enforce secure communication settings:
```python
USE_RBAC = True
USE_SAML = True
USE_IDENTITY_PROVIDERS = True
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
SOCIALACCOUNT_PROVIDERS = {
"saml": {
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
}
}
```
These settings enable SAML authentication, configure MediaCMS to respect role-based access, and apply important headers and cookie policies for secure browser handling — all of which are necessary for the SAML flow to function properly.
> ⚠️ **Important**: After updating the `local_settings.py` file, you must restart your MediaCMS service (e.g., by rebooting the Docker container) in order for the changes to take effect. This step must be completed before proceeding to the next configuration stage.
---
## Step 2: Register MediaCMS as an Enterprise App in Entra ID
To begin the integration process on the Microsoft Entra ID (formerly Azure AD) side, follow the steps below to register MediaCMS as a new Enterprise Application.
### 1. Navigate to Enterprise Applications
* Log in to your [Azure Portal](https://portal.azure.com).
* Navigate to **Enterprise Applications**.
> *Note: This guide assumes you already have an existing Azure tenant and Entra ID configured with users and groups.*
### 2. Create a New Application
* Click the **+ New Application** button.
* On the next screen, choose **Create your own application**.
* Enter a name for the application (e.g., `MediaCMS`).
* Under "What are you looking to do with your application?", select **Integrate any other application you don't find in the gallery (Non-gallery)**.
* Click **Create**.
After a few moments, Azure will create the new application and redirect you to its configuration page.
---
## Step 3: Configure SAML Settings in Entra ID
### 1. Configure SAML-Based Single Sign-On
* From the application overview page, in the left-hand menu under **Manage**, click **Single sign-on**.
* You will be prompted to choose a sign-on method. Select **SAML**.
### 2. Choose a Client ID Name
Before filling out the SAML configuration, you must decide on a client ID name. This name will uniquely identify your SAML integration and appear in your login URL.
* Choose a name that is descriptive and easy to remember (e.g., `mediacms_entraid`).
* You will use this name in both MediaCMS and Entra ID configuration settings.
### 3. Fill Out Basic SAML Configuration
Now input the following values under the **Basic SAML Configuration** section:
| Field | Value |
| -------------------------- | --------------------------------------------------------------------- |
| **Identifier (Entity ID)** | `https://<MyMediaCMS.MyDomain.com>/saml/metadata/` |
| **Reply URL (ACS URL)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/` |
| **Sign-on URL** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/` |
| **Relay State (Optional)** | `https://<MyMediaCMS.MyDomain.com>/` |
| **Logout URL (Optional)** | `https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/sls/` |
> 🔐 Replace `<MyClientID>` with your own chosen client ID if different.
Once these fields are filled in, save your configuration.
Keep the Azure Enterprise single sign-on configuration window up, as we are now going to configure some of the details from this Azure page into our MediaCMS system.
---
## Step 4: Configure SAML Settings in MediaCMS
In MediaCMS, start by logging into the back-end administrative web page. You will now have new options under the left-hand menu bar.
### 1. Add Login Option
* Navigate to **Identity Providers → Login Options**.
* Click **Add Login Option**.
* Give the login option a title. This title can be anything you like but it will appear to the end-user when they select a method of logging in, so ensure the name is clear. (e.g., `EntraID-SSO`).
* Set the **Login URL** to the same Sign-on URL:
```
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/login/
```
* Leave the ordering at `0` if you have no other authentication methods.
* Ensure the **Active** box is checked to make this an active login method.
* Click **Save** to continue.
### 2. Add ID Provider
* Navigate to **Identity Providers → ID Providers**.
* Click **Add ID Provider**.
Back in your Azure Enterprise application configuration window (at the bottom of the Single Sign-On configuration menu), find your application-specific details. They will look like the following example:
```
Example unique AppID: 123456ab-1234-12ab-ab12-abc123abc123
The unique AppID is automatically generated when you create the application.
-- Example URLs --
Login URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
Microsoft Entra Identifier: https://sts.windows.net/123456ab-1234-12ab-ab12-abc123abc123/
Logout URL: https://login.microsoftonline.com/123456ab-1234-12ab-ab12-abc123abc123/saml2
```
Back in MediaCMS's new ID Provider window, under the **General** tab:
* **Protocol**: `saml` (all lowercase)
* **Provider ID**: The Microsoft Entra Identifier (as shown above), the whole URL.
* **IDP Configuration Name**: Any unique name (e.g., `EntraID`)
* **Client ID**: The exact same client ID you used earlier when configuring EntraID (e.g., `mediacms_entraid`).
* **Sites**: Add all the sites you want this login to appear on (e.g., all of them)
Click **Save and Continue**, then go to the **SAML Configuration** tab.
On the **SAML Configuration** tab:
* **SSO URL**: Use the same Logon URL from EntraID example listed above.
* **SLO URL**: Use the Logout URL from EntraID example listed above.
* **SP Metadata URL**:
```
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
```
* **IdP ID**: Use the same Microsoft Entra Identifier URL as listed above.
#### LDP Certificate
Back in Azure's Enterprise Application page (SAML certificates section), download the **Base64 Certificate**, open it in a text editor, and copy the contents into the **LDP Certificate** setting inside of MediaCMS.
### 3. Configure Identity Mappings
Map the identity attributes that Entra ID will provide to MediaCMS. Even though only UID is specified as mandatory, Entra ID will not work unless all of these details are filled in(YES, you must type NA in the fields; you cannot leave anything blank. You will get 500 errors if this is not done). You can use the exact settings below:
| Field | Value |
| -------------- | -------------------------------------------------------------------- |
| **Uid** | `http://schemas.microsoft.com/identity/claims/objectidentifier` |
| **Name** | `http://schemas.microsoft.com/identity/claims/displayname` |
| **Email** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` |
| **Groups** | `NA` |
| **First name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` |
| **Last name** | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` |
| **User logo** | `NA` |
| **Role** | `NA` |
> Groups and Role can be changed or remapped inside the Azure Enterprise Application under **Attributes and Claims**.
Check the **Verified Email** box (since EntraID will verify the user for you). While setting up, you can enable **Save SAML Response Log** for troubleshooting purposes.
Finally, click **Save** to finish adding the new ID provider.
---
## Step 5: Allow Users or Groups to Log Into the Application
Back inside Azure AD, within your MediaCMS Enterprise Application, you must assign users or groups that are allowed to use the MediaCMS authentication sign-on.
### 1. Navigate to Users and Groups
* Open the Azure Portal and go to your **MediaCMS Enterprise Application**.
* In the left-hand **Manage** menu, click **Users and Groups**.
### 2. Assign Users or Groups
* Add individual users or groups of users who are allowed to use the EntraID authentication method with MediaCMS.
* In this example, the application was provided to all registered users inside of EntraID by using the special group **All Users**, which grants any registered user in the tenant access to MediaCMS.
> ⚠️ **Important**: Nested groups will not work. All users must be directly assigned to the group you are giving permission to. If a group contains another group, the users of the nested group will not inherit the permissions to use this application from the parent group.
---
## Step 6: Test and Validate Login Flow
At this point, you should go to your MediaCMS webpage and attempt to log in using the authentication method that you have just set up.
---
## Troubleshooting
If you're experiencing logon issues, it is helpful to first review the SAML authentication data directly.
1. Go to MediaCMS's login page. It should redirect you to Microsoft's login page.
2. Before completing the Microsoft authentication, open Firefox or Chrome Developer Tools (press **F12**) and navigate to the **Network** tab.
3. Enable **Persistent Logging**.
4. Complete the Microsoft authentication steps on your page (including two-factor authentication if enabled).
On the final step of the authentication (usually after entering a code and confirming "Stay signed in?"), you will see several POST requests going back to your MediaCMS server URL. Find the POST request that is going to your MediaCMS server's Assertion Consumer Service (ACS) URL, which will look like this:
```
https://<MyMediaCMS.MyDomain.com>/accounts/saml/<MyClientID>/acs/
```
Inside the request section of the Network tab, you will see a **Form Data** field labeled **SAMLResponse**, which contains a Base64-encoded XML string of your authenticated assertion from EntraID.
* Click into the data field of the SAML response so you can highlight and copy all of the Base64-encoded text.
* You can then take this Base64-encoded text to a tool like [CyberChef](https://gchq.github.io/CyberChef/) and use the **From Base64** decoder and **XML Beautify** to reveal the XML-formatted SAML response.
This decoded XML contains all the assertion and token details passed back to MediaCMS. You can use this information to troubleshoot any issues or misconfigurations that arise.
You can also confirm your MediaCMS server has the SAML authentication settings correct by opening a private browsing window and navigating to the following URL, which will output the current XML data that your MediaCMS server is configured with:
```
https://<MyMediaCMS.MyDomain.com>/saml/metadata/
```
You can use the returned XML data from this URL to confirm that MediaCMS is configured appropriately as expected and is providing the correct information to the identity provider.
### Infinite Redirect Loop
Another issue you might encounter is an **infinite redirect loop**. This can happen when global login is enforced and local user login is disabled.
**Symptoms:** The system continuously redirects between the homepage and the login URL.
**Root Cause:** With global login required and local login disabled, Django attempts to redirect users to the default local login page. Since that login method is unavailable, users are bounced back to the homepage, triggering the same redirect logic again — resulting in a loop.
**Solution:** Specify the correct SAML authentication URL in your local settings. For example:
* "Login Option" URL configured for EntraID in MediaCMS:
```
https://<MyDomainName>/accounts/saml/mediacms_entraid/login/
```
* Add the following line to `./deploy/docker/local_settings.py`:
```python
LOGIN_URL = "/accounts/saml/mediacms_entraid/login/"
```
This change ensures Django uses the proper SAML login route, breaking the redirect loop and allowing authentication via EntraID as intended.
> **Note:** The `LOGIN_URL` setting works because we are using the Django AllAuth module to perform the SAML authentication. If you review the AllAuth Django configuration settings, you will find that this is a setting, among other settings, that you can set inside of your local settings file that Django will pick up when using the AllAuth module. You can review the module documentation at the following URL for more details and additional settings that can be set through AllAuth via `local_settings.py`: [https://django-allauth.readthedocs.io/en/latest/account/configuration.html](https://django-allauth.readthedocs.io/en/latest/account/configuration.html)
---
## Resources
* [MediaCMS SAML Docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#24-identity-providers-setup)
* [Enable SAML single sign-on for an enterprise application](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/add-application-portal-setup-sso)
* [Django AllAuth](https://django-allauth.readthedocs.io/en/latest/index.html)
---
*This documentation is a work-in-progress and will be updated as further steps are dictated or completed.*

View File

@@ -26,10 +26,22 @@ def stuff(request):
ret["UPLOAD_MAX_SIZE"] = settings.UPLOAD_MAX_SIZE
ret["UPLOAD_MAX_FILES_NUMBER"] = settings.UPLOAD_MAX_FILES_NUMBER
ret["PRE_UPLOAD_MEDIA_MESSAGE"] = settings.PRE_UPLOAD_MEDIA_MESSAGE
ret["SIDEBAR_FOOTER_TEXT"] = settings.SIDEBAR_FOOTER_TEXT
ret["POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY"] = settings.POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY
ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser
ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user)
ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user)
ret["USERS_NEEDS_TO_BE_APPROVED"] = settings.USERS_NEEDS_TO_BE_APPROVED
can_see_members_page = False
if request.user.is_authenticated:
if settings.CAN_SEE_MEMBERS_PAGE == "all":
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "editors" and is_mediacms_editor(request.user):
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "admins" and request.user.is_superuser:
can_see_members_page = True
ret["CAN_SEE_MEMBERS_PAGE"] = can_see_members_page
ret["ALLOW_RATINGS"] = settings.ALLOW_RATINGS
ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY
ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE

View File

@@ -222,12 +222,12 @@ class WhisperSubtitlesForm(forms.ModelForm):
"allow_whisper_transcribe_and_translate",
)
labels = {
"allow_whisper_transcribe": "automatic transcription",
"allow_whisper_transcribe_and_translate": "automatic transcription and translation",
"allow_whisper_transcribe": "Transcription",
"allow_whisper_transcribe_and_translate": "English Translation",
}
help_texts = {
"allow_whisper_transcribe": "Request automatic transcription for this media.",
"allow_whisper_transcribe_and_translate": "Request automatic transcription and translation for this media.",
"allow_whisper_transcribe": "",
"allow_whisper_transcribe_and_translate": "",
}
def __init__(self, user, *args, **kwargs):
@@ -281,7 +281,7 @@ class SubtitleForm(forms.ModelForm):
fields = ["language", "subtitle_file"]
labels = {
"subtitle_file": "Subtitle or Closed Caption File",
"subtitle_file": "Upload Caption File",
}
help_texts = {
"subtitle_file": "SubRip (.srt) and WebVTT (.vtt) are supported file formats.",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "ترجمات",
"Tags": "العلامات",
"Terms": "الشروط",
"This works in Chrome, Safari and Edge browsers.": "هذا يعمل في متصفحات Chrome و Safari و Edge.",
"Trim": "قص",
"UPLOAD": "رفع",
"Up next": "التالي",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "সাবটাইটেল",
"Tags": "ট্যাগ",
"Terms": "শর্তাবলী",
"This works in Chrome, Safari and Edge browsers.": "এটি ক্রোম, সাফারি এবং এজ ব্রাউজারে কাজ করে।",
"Trim": "ছাঁটাই",
"UPLOAD": "আপলোড করুন",
"Up next": "পরবর্তী",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Undertekster",
"Tags": "Tags",
"Terms": "Vilkår",
"This works in Chrome, Safari and Edge browsers.": "Dette virker i Chrome, Safari og Edge browsere.",
"Trim": "Beskær",
"UPLOAD": "UPLOAD",
"Up next": "Næste",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Untertitel",
"Tags": "Tags",
"Terms": "Bedingungen",
"This works in Chrome, Safari and Edge browsers.": "Dies funktioniert in den Browsern Chrome, Safari und Edge.",
"Trim": "Trimmen",
"UPLOAD": "HOCHLADEN",
"Up next": "Als nächstes",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Υπότιτλοι",
"Tags": "Ετικέτες",
"Terms": "Όροι",
"This works in Chrome, Safari and Edge browsers.": "Αυτό λειτουργεί σε προγράμματα περιήγησης Chrome, Safari και Edge.",
"Trim": "Περικοπή",
"UPLOAD": "ΑΝΕΒΑΣΜΑ",
"Up next": "Επόμενο",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitle was added": "",
"Tags": "",
"Terms": "",
"This works in Chrome, Safari and Edge browsers.": "",
"Trim": "",
"UPLOAD": "",
"Up next": "",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Subtítulos",
"Tags": "Etiquetas",
"Terms": "Términos",
"This works in Chrome, Safari and Edge browsers.": "Esto funciona en los navegadores Chrome, Safari y Edge.",
"Trim": "Recortar",
"UPLOAD": "SUBIR",
"Up next": "A continuación",

View File

@@ -66,6 +66,7 @@ translation_strings = {
"Subtitles": "Sous-titres",
"Tags": "Tags",
"Terms": "Conditions",
"This works in Chrome, Safari and Edge browsers.": "Cela fonctionne dans les navigateurs Chrome, Safari et Edge.",
"Trim": "Couper",
"UPLOAD": "TÉLÉCHARGER",
"Up next": "À suivre",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "כתוביות",
"Tags": "תגיות",
"Terms": "תנאים",
"This works in Chrome, Safari and Edge browsers.": "זה עובד בדפדפני Chrome, Safari ו-Edge.",
"Trim": "גזירה",
"UPLOAD": "העלה",
"Up next": "הבא בתור",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "उपशीर्षक",
"Tags": "टैग",
"Terms": "शर्तें",
"This works in Chrome, Safari and Edge browsers.": "यह क्रोम, सफारी और एज ब्राउज़र में काम करता है।",
"Trim": "छांटें",
"UPLOAD": "अपलोड करें",
"Up next": "अगला",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Subtitel",
"Tags": "Tag",
"Terms": "Ketentuan",
"This works in Chrome, Safari and Edge browsers.": "Ini berfungsi di browser Chrome, Safari, dan Edge.",
"Trim": "Potong",
"UPLOAD": "UNGGAH",
"Up next": "Selanjutnya",

View File

@@ -66,6 +66,7 @@ translation_strings = {
"Subtitles": "Sottotitoli",
"Tags": "Tag",
"Terms": "Termini e condizioni",
"This works in Chrome, Safari and Edge browsers.": "Questo funziona nei browser Chrome, Safari e Edge.",
"Trim": "Taglia",
"UPLOAD": "CARICA",
"Up next": "A seguire",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "字幕",
"Tags": "タグ",
"Terms": "利用規約",
"This works in Chrome, Safari and Edge browsers.": "これはChrome、Safari、Edgeブラウザで動作します。",
"Trim": "トリム",
"UPLOAD": "アップロード",
"Up next": "次に再生",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "자막",
"Tags": "태그",
"Terms": "약관",
"This works in Chrome, Safari and Edge browsers.": "이 기능은 Chrome, Safari 및 Edge 브라우저에서 작동합니다.",
"Trim": "자르기",
"UPLOAD": "업로드",
"Up next": "다음",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Ondertitels",
"Tags": "Tags",
"Terms": "Voorwaarden",
"This works in Chrome, Safari and Edge browsers.": "Dit werkt in Chrome, Safari en Edge browsers.",
"Trim": "Bijsnijden",
"UPLOAD": "UPLOADEN",
"Up next": "Hierna",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Legendas",
"Tags": "Tags",
"Terms": "Termos",
"This works in Chrome, Safari and Edge browsers.": "Isso funciona nos navegadores Chrome, Safari e Edge.",
"Trim": "Cortar",
"UPLOAD": "CARREGAR",
"Up next": "A seguir",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Субтитры",
"Tags": "Теги",
"Terms": "Условия",
"This works in Chrome, Safari and Edge browsers.": "Это работает в браузерах Chrome, Safari и Edge.",
"Trim": "Обрезать",
"UPLOAD": "ЗАГРУЗИТЬ",
"Up next": "Далее",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Podnapisi",
"Tags": "Oznake",
"Terms": "Pogoji",
"This works in Chrome, Safari and Edge browsers.": "To deluje v brskalnikih Chrome, Safari in Edge.",
"Trim": "Obreži",
"UPLOAD": "NALOŽI",
"Up next": "Naslednji",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "Altyazılar",
"Tags": "Etiketler",
"Terms": "Şartlar",
"This works in Chrome, Safari and Edge browsers.": "Bu, Chrome, Safari ve Edge tarayıcılarında çalışır.",
"Trim": "Kırp",
"UPLOAD": "YÜKLE",
"Up next": "Sıradaki",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "سب ٹائٹلز",
"Tags": "ٹیگز",
"Terms": "شرائط",
"This works in Chrome, Safari and Edge browsers.": "یہ کروم، سفاری اور ایج براؤزرز میں کام کرتا ہے۔",
"Trim": "تراشیں",
"UPLOAD": "اپ لوڈ کریں",
"Up next": "اگلا",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "字幕",
"Tags": "标签",
"Terms": "条款",
"This works in Chrome, Safari and Edge browsers.": "此功能适用于 Chrome、Safari 和 Edge 浏览器。",
"Trim": "修剪",
"UPLOAD": "上传",
"Up next": "接下来",

View File

@@ -65,6 +65,7 @@ translation_strings = {
"Subtitles": "字幕",
"Tags": "標籤",
"Terms": "使用條款",
"This works in Chrome, Safari and Edge browsers.": "此功能適用於 Chrome、Safari 和 Edge 瀏覽器。",
"Trim": "修剪",
"UPLOAD": "上傳",
"Up next": "即將播放",

View File

@@ -1,3 +1,5 @@
from django.conf import settings
from django.db.models import Q
from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
@@ -219,6 +221,13 @@ class UserList(APIView):
elif role == "editor":
qs = qs.filter(is_editor=True)
if settings.USERS_NEEDS_TO_BE_APPROVED:
is_approved = request.GET.get("is_approved")
if is_approved == "true":
qs = qs.filter(is_approved=True)
elif is_approved == "false":
qs = qs.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
users = qs.order_by(f"{ordering}{sort_by}")
paginator = pagination_class()

View File

@@ -138,7 +138,7 @@ class Tag(models.Model):
def save(self, *args, **kwargs):
self.title = helpers.get_alphanumeric_only(self.title)
self.title = self.title[:99]
self.title = self.title[:100]
super(Tag, self).save(*args, **kwargs)
@property

View File

@@ -245,7 +245,7 @@ class Media(models.Model):
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]
self.title = self.title[:100]
# if thumbnail_time specified, keep up to single digit
if self.thumbnail_time:

View File

@@ -64,7 +64,7 @@ class Playlist(models.Model):
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]
self.title = self.title[:100]
if not self.friendly_token:
while True:

View File

@@ -484,11 +484,11 @@ def whisper_transcribe(friendly_token, translate_to_english=False):
if translate_to_english:
language = Language.objects.filter(code="whisper-translation").first()
if not language:
language = Language.objects.create(code="whisper-translation", title="Automatic Transcription and Translation")
language = Language.objects.create(code="whisper-translation", title="English Translation")
else:
language = Language.objects.filter(code="whisper").first()
if not language:
language = Language.objects.create(code="whisper", title="Automatic Transcription")
language = Language.objects.create(code="whisper", title="Transcription")
cwd = os.path.dirname(os.path.realpath(media.media_file.path))
request.status = "running"

View File

@@ -110,6 +110,9 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.USERS_NEEDS_TO_BE_APPROVED:
urlpatterns.append(re_path(r"^approval_required", views.approval_required, name="approval_required"))
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))

View File

@@ -1,4 +1,5 @@
# 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
@@ -10,6 +11,7 @@ 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 approval_required # noqa: F401
from .pages import categories # noqa: F401
from .pages import contact # noqa: F401
from .pages import edit_chapters # noqa: F401

View File

@@ -54,6 +54,11 @@ def about(request):
return render(request, "cms/about.html", context)
def approval_required(request):
"""User needs approval view"""
return render(request, "cms/user_needs_approval.html", {})
def setlanguage(request):
"""Set Language view"""
@@ -88,7 +93,7 @@ def add_subtitle(request):
subtitle = form.save()
try:
subtitle.convert_to_srt()
messages.add_message(request, messages.INFO, "Subtitle was added!")
messages.add_message(request, messages.INFO, "Caption was added!")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
except Exception as e: # noqa
subtitle.delete()
@@ -147,7 +152,7 @@ def edit_subtitle(request):
elif request.method == "POST":
confirm = request.GET.get("confirm", "").strip()
if confirm == "true":
messages.add_message(request, messages.INFO, "Subtitle was deleted")
messages.add_message(request, messages.INFO, "Caption was deleted")
redirect_url = subtitle.media.get_absolute_url()
subtitle.delete()
return HttpResponseRedirect(redirect_url)
@@ -156,7 +161,7 @@ def edit_subtitle(request):
with open(subtitle.subtitle_file.path, "w") as ff:
ff.write(subtitle_text)
messages.add_message(request, messages.INFO, "Subtitle was edited")
messages.add_message(request, messages.INFO, "Caption was edited")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
return render(request, "cms/edit_subtitle.html", context)
@@ -517,6 +522,12 @@ def manage_comments(request):
def members(request):
"""List members view"""
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/members.html", context)

View File

@@ -1,7 +1,8 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { usePopup } from '../../../utils/hooks/';
import { usePopup, useUser } from '../../../utils/hooks/';
import { PageStore } from '../../../utils/stores/';
import { csrfToken } from '../../../utils/helpers/';
import { PopupMain } from '../../_shared';
import { MaterialIcon } from '../../_shared/material-icon/MaterialIcon.jsx';
import { ManageItemDate } from './ManageMediaItem';
@@ -38,34 +39,86 @@ function ManageItemUsername(props) {
return <i className="non-available">N/A</i>;
}
function ManageItemCommentActions(props) {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
const [isOpenPopup, setIsOpenPopup] = useState(false);
function ManageUsersItemActions(props) {
const { userCan } = useUser();
const [deletePopupRef, DeletePopupContent, DeletePopupTrigger] = usePopup();
const [passwordPopupRef, PasswordPopupContent, PasswordPopupTrigger] = usePopup();
const [approvePopupRef, ApprovePopupContent, ApprovePopupTrigger] = usePopup();
function onPopupShow() {
setIsOpenPopup(true);
}
const [newPassword, setNewPassword] = useState('');
function onPopupHide() {
setIsOpenPopup(false);
}
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [isPasswordOpen, setPasswordOpen] = useState(false);
const [isApproveOpen, setApproveOpen] = useState(false);
function onCancel() {
popupContentRef.current.tryToHide();
if ('function' === typeof props.onCancel) {
props.onCancel();
}
}
function onProceed() {
popupContentRef.current.tryToHide();
function onProceedDelete() {
deletePopupRef.current.tryToHide();
if ('function' === typeof props.onProceed) {
props.onProceed();
}
}
function onCancelDelete() {
deletePopupRef.current.tryToHide();
}
function handlePasswordChangeSubmit(e) {
e.preventDefault();
props.setMessage({ type: '', text: '' });
const formData = new FormData();
formData.append('action', 'change_password');
formData.append('password', newPassword);
fetch(`/api/v1/users/${props.username}`, {
method: 'PUT',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to change password.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'Password changed successfully.' }));
window.location.reload();
})
.catch((err) => {
props.setMessage({ type: 'error', text: err.message });
});
}
function handleApproveUser() {
props.setMessage({ type: '', text: '' });
const formData = new FormData();
formData.append('action', 'approve_user');
fetch(`/api/v1/users/${props.username}`, {
method: 'PUT',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to approve user.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'User approved successfully.' }));
window.location.reload();
})
.catch((err) => {
props.setMessage({ type: 'error', text: err.message });
});
}
const positionState = { updating: false, pending: 0 };
const onWindowResize = useCallback(function () {
if (positionState.updating) {
positionState.pending = positionState.pending + 1;
@@ -98,6 +151,8 @@ function ManageItemCommentActions(props) {
}
}, []);
const isOpenPopup = isDeleteOpen || isPasswordOpen || isApproveOpen;
useEffect(() => {
if (isOpenPopup) {
PageStore.on('window_scroll', onWindowResize);
@@ -111,11 +166,94 @@ function ManageItemCommentActions(props) {
return (
<div ref={props.containerRef} className="actions">
<PopupTrigger contentRef={popupContentRef}>
<PasswordPopupTrigger contentRef={passwordPopupRef}>
<button>Change password</button>
</PasswordPopupTrigger>
{userCan.usersNeedsToBeApproved && !props.is_approved && (
<>
<span className="seperator">|</span>
<ApprovePopupTrigger contentRef={approvePopupRef}>
<button>Approve</button>
</ApprovePopupTrigger>
</>
)}
<span className="seperator">|</span>
<DeletePopupTrigger contentRef={deletePopupRef}>
<button title={'Delete "' + props.name + '"'}>Delete</button>
</PopupTrigger>
</DeletePopupTrigger>
<PopupContent contentRef={popupContentRef} showCallback={onPopupShow} hideCallback={onPopupHide}>
<PasswordPopupContent
contentRef={passwordPopupRef}
showCallback={() => setPasswordOpen(true)}
hideCallback={() => {
setPasswordOpen(false);
props.setMessage({ type: '', text: '' });
}}
>
<PopupMain>
<form onSubmit={handlePasswordChangeSubmit}>
<div className="popup-message">
<span className="popup-message-title">Change Password for {props.name}</span>
<span className="popup-message-main">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New Password"
required
style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
/>
</span>
</div>
<hr />
<span className="popup-message-bottom">
<button
type="button"
className="button-link cancel-profile-removal"
onClick={() => passwordPopupRef.current.tryToHide()}
>
CANCEL
</button>
<button type="submit" className="button-link proceed-profile-removal">
SUBMIT
</button>
</span>
</form>
</PopupMain>
</PasswordPopupContent>
<ApprovePopupContent
contentRef={approvePopupRef}
showCallback={() => setApproveOpen(true)}
hideCallback={() => {
setApproveOpen(false);
props.setMessage({ type: '', text: '' });
}}
>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Approve User</span>
<span className="popup-message-main">
{'Are you sure you want to approve "' + props.name + '"?'}
</span>
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-profile-removal" onClick={() => approvePopupRef.current.tryToHide()}>
CANCEL
</button>
<button className="button-link proceed-profile-removal" onClick={handleApproveUser}>
PROCEED
</button>
</span>
</PopupMain>
</ApprovePopupContent>
<DeletePopupContent
contentRef={deletePopupRef}
showCallback={() => setDeleteOpen(true)}
hideCallback={() => setDeleteOpen(false)}
>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Member removal</span>
@@ -123,15 +261,15 @@ function ManageItemCommentActions(props) {
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-profile-removal" onClick={onCancel}>
<button className="button-link cancel-profile-removal" onClick={onCancelDelete}>
CANCEL
</button>
<button className="button-link proceed-profile-removal" onClick={onProceed}>
<button className="button-link proceed-profile-removal" onClick={onProceedDelete}>
PROCEED
</button>
</span>
</PopupMain>
</PopupContent>
</DeletePopupContent>
</div>
);
}
@@ -168,10 +306,14 @@ export function ManageUsersItem(props) {
</div>
<div className="mi-name">
<ManageItemName name={props.name} url={props.url} />
<ManageItemCommentActions
<ManageUsersItemActions
containerRef={actionsContainerRef}
name={props.name || props.username}
username={props.username}
is_approved={props.is_approved}
onProceed={onClickProceed}
onUserUpdate={props.onUserUpdate}
setMessage={props.setMessage}
/>
</div>
<div className="mi-username">
@@ -213,6 +355,17 @@ export function ManageUsersItem(props) {
)}
</div>
) : null}
{props.has_approved ? (
<div className="mi-approved">
{void 0 === props.is_approved || props.is_approved === null ? (
<i className="non-available">N/A</i>
) : props.is_approved ? (
<MaterialIcon type="check_circle" />
) : (
<MaterialIcon type="cancel" />
)}
</div>
) : null}
<div className="mi-featured">
{void 0 === props.is_featured ? (
<i className="non-available">N/A</i>
@@ -234,18 +387,25 @@ ManageUsersItem.propTypes = {
add_date: PropTypes.string,
is_featured: PropTypes.bool,
onCheckRow: PropTypes.func,
onUserUpdate: PropTypes.func,
setMessage: PropTypes.func,
selectedRow: PropTypes.bool.isRequired,
hideDeleteAction: PropTypes.bool.isRequired,
has_roles: PropTypes.bool,
has_verified: PropTypes.bool,
has_trusted: PropTypes.bool,
has_approved: PropTypes.bool,
roles: PropTypes.array,
is_verified: PropTypes.bool,
is_trusted: PropTypes.bool,
is_approved: PropTypes.bool,
};
ManageUsersItem.defaultProps = {
has_roles: false,
has_verified: false,
has_trusted: false,
has_approved: false,
onUserUpdate: () => {},
setMessage: () => {},
};

View File

@@ -45,6 +45,7 @@ export function ManageUsersItemHeader(props) {
{props.has_roles ? <div className="mi-role">Role</div> : null}
{props.has_verified ? <div className="mi-verified">Verified</div> : null}
{props.has_trusted ? <div className="mi-trusted">Trusted</div> : null}
{props.has_approved ? <div className="mi-approved">Approved</div> : null}
<div className="mi-featured">Featured</div>
</div>
);
@@ -59,10 +60,12 @@ ManageUsersItemHeader.propTypes = {
has_roles: PropTypes.bool,
has_verified: PropTypes.bool,
has_trusted: PropTypes.bool,
has_approved: PropTypes.bool,
};
ManageUsersItemHeader.defaultProps = {
has_roles: false,
has_verified: false,
has_trusted: false,
has_approved: false,
};

View File

@@ -12,6 +12,89 @@ import { translateString } from '../../../utils/helpers/';
import './ManageItemList.scss';
function AddNewUser({ onUserAdded, setMessage }) {
const [popupRef, PopupContent, PopupTrigger] = usePopup();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
function clearForm() {
setUsername('');
setPassword('');
setEmail('');
setName('');
}
function handleSubmit(e) {
e.preventDefault();
if (setMessage) {
setMessage({ type: '', text: '' });
}
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
formData.append('email', email);
formData.append('name', name);
fetch('/api/v1/users', {
method: 'POST',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to create user.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'User created successfully.' }));
window.location.reload();
})
.catch((err) => {
if (setMessage) {
setMessage({ type: 'error', text: err.message });
}
});
}
return (
<div className="add-new-user-container">
<PopupTrigger contentRef={popupRef}>
<button className="add-new-user-btn">Add New User</button>
</PopupTrigger>
<PopupContent contentRef={popupRef} hideCallback={clearForm}>
<PopupMain>
<form onSubmit={handleSubmit}>
<div className="popup-message">
<span className="popup-message-title">Add New User</span>
<div className="popup-message-main">
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
</div>
</div>
<hr />
<span className="popup-message-bottom">
<button type="button" className="button-link cancel-profile-removal" onClick={() => popupRef.current.tryToHide()}>CANCEL</button>
<button type="submit" className="button-link proceed-profile-removal">SUBMIT</button>
</span>
</form>
</PopupMain>
</PopupContent>
</div>
);
}
AddNewUser.propTypes = {
onUserAdded: PropTypes.func,
setMessage: PropTypes.func,
};
function useManageItemList(props, itemsListRef) {
let previousItemsLength = 0;
@@ -440,6 +523,35 @@ export function ManageItemList(props) {
onItemsLoad,
] = useManageItemListSync(props);
const [message, setMessage] = useState({ type: '', text: '' });
useEffect(() => {
const storedMessage = sessionStorage.getItem('user-management-message');
if (storedMessage) {
setMessage(JSON.parse(storedMessage));
sessionStorage.removeItem('user-management-message');
}
}, []);
useEffect(() => {
if (message.text) {
const timer = setTimeout(() => setMessage({ type: '', text: '' }), 5000);
return () => clearTimeout(timer);
}
}, [message]);
function refreshList() {
if (props.onPageChange && parsedRequestUrl) {
const queryParams = new URLSearchParams(parsedRequestUrlQuery || '');
const currentPage = queryParams.get('page') || '1';
const clickedPageUrl = pageUrl(parsedRequestUrl, pageUrlQuery(parsedRequestUrlQuery, currentPage));
props.onPageChange(clickedPageUrl, currentPage);
} else {
// Fallback for when onPageChange is not available
setListHandler(new ManageItemsListHandler(props.pageItems, props.maxItems, props.requestUrl, onItemsCount, onItemsLoad));
}
}
const [selectedItems, setSelectedItems] = useState([]);
const [selectedAllItems, setSelectedAllItems] = useState(false);
@@ -592,50 +704,60 @@ export function ManageItemList(props) {
return () => {
if (listHandler) {
listHandler.cancelAll();
// listHandler.cancelAll();
setListHandler(null);
}
};
}, []);
}, [props.requestUrl]);
return !countedItems ? (
<PendingItemsList className={classname.listOuter} />
) : !items.length ? null : (
) : (
<div className={classname.listOuter}>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
{message.text && (
<div className={`message ${message.type === 'error' ? 'error' : 'success'}`}>{message.text}</div>
)}
{'users' === props.manageType && <AddNewUser onUserAdded={refreshList} setMessage={setMessage} />}
{!items.length ? null : (
<>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
<div ref={itemsListWrapperRef} className="items-list-wrap">
<div ref={itemsListRef} className={classname.list}>
{renderManageItems(items, {
...props,
onAllRowsCheck: onAllRowsCheck,
onRowCheck: onRowCheck,
selectedItems: selectedItems,
selectedAllItems: selectedAllItems,
onDelete: deleteItem,
})}
</div>
</div>
<div ref={itemsListWrapperRef} className="items-list-wrap">
<div ref={itemsListRef} className={classname.list}>
{renderManageItems(items, {
...props,
onAllRowsCheck: onAllRowsCheck,
onRowCheck: onRowCheck,
selectedItems: selectedItems,
selectedAllItems: selectedAllItems,
onDelete: deleteItem,
onUserUpdate: refreshList,
setMessage: setMessage,
})}
</div>
</div>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options popup-on-top"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options popup-on-top"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
</>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { ManageCommentsItem } from '../../ManageItem/ManageCommentsItem';
import { ManageMediaItemHeader } from '../../ManageItem/ManageMediaItemHeader';
import { ManageUsersItemHeader } from '../../ManageItem/ManageUsersItemHeader';
import { ManageCommentsItemHeader } from '../../ManageItem/ManageCommentsItemHeader';
import { useUser } from '../../../../utils/hooks/';
function useManageItem(props) {
const itemData = props.item;
@@ -15,6 +16,8 @@ function useManageItem(props) {
selectedRow: props.selectedRow,
onProceedRemoval: props.onProceedRemoval,
hideDeleteAction: props.hideDeleteAction,
onUserUpdate: props.onUserUpdate,
setMessage: props.setMessage,
};
return [itemData, itemProps];
@@ -44,6 +47,7 @@ function ListManageMediaItem(props) {
}
function ListManageUserItem(props) {
const { userCan } = useUser();
const [itemData, itemProps] = useManageItem(props);
const roles = [];
@@ -70,6 +74,8 @@ function ListManageUserItem(props) {
has_roles: void 0 !== itemData.is_editor || void 0 !== itemData.is_manager,
has_verified: void 0 !== itemData.email_is_verified,
has_trusted: void 0 !== itemData.advancedUser,
is_approved: itemData.is_approved,
has_approved: userCan.usersNeedsToBeApproved && void 0 !== itemData.is_approved,
};
return <ManageUsersItem {...args} />;
@@ -99,6 +105,8 @@ function ListManageItem(props) {
hideDeleteAction: false,
onCheckRow: props.onCheckRow,
onProceedRemoval: props.onProceedRemoval,
onUserUpdate: props.onUserUpdate,
setMessage: props.setMessage,
};
if ('media' === props.type) {
@@ -117,6 +125,7 @@ function ListManageItem(props) {
}
function ListManageItemHeader(props) {
const { userCan } = useUser();
const args = {
sort: props.sort,
order: props.order,
@@ -134,6 +143,10 @@ function ListManageItemHeader(props) {
props.items.length && (void 0 !== props.items[0].is_editor || void 0 !== props.items[0].is_manager);
args.has_verified = props.items.length && void 0 !== props.items[0].email_is_verified;
args.has_trusted = props.items.length && void 0 !== props.items[0].advancedUser;
args.has_approved =
userCan.usersNeedsToBeApproved &&
props.items.length &&
void 0 !== props.items[0].is_approved;
return <ManageUsersItemHeader {...args} />;
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { useUser } from '../../utils/hooks/';
import { FilterOptions } from '../_shared';
import './ManageItemList-filters.scss';
@@ -11,12 +12,19 @@ const filters = {
{ id: 'editor', title: 'Editor' },
{ id: 'manager', title: 'Manager' },
],
approved: [
{ id: 'all', title: 'All' },
{ id: 'true', title: 'Yes' },
{ id: 'false', title: 'No' },
],
};
export function ManageUsersFilters(props) {
const { userCan } = useUser();
const [isHidden, setIsHidden] = useState(props.hidden);
const [role, setFilterRole] = useState('all');
const [approved, setFilterApproved] = useState('all');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
@@ -30,6 +38,7 @@ export function ManageUsersFilters(props) {
function onFilterSelect(ev) {
const args = {
role: role,
is_approved: approved,
};
switch (ev.currentTarget.getAttribute('filter')) {
@@ -38,6 +47,11 @@ export function ManageUsersFilters(props) {
props.onFiltersUpdate(args);
setFilterRole(args.role);
break;
case 'approved':
args.is_approved = ev.currentTarget.getAttribute('value');
props.onFiltersUpdate(args);
setFilterApproved(args.is_approved);
break;
}
}
@@ -60,6 +74,14 @@ export function ManageUsersFilters(props) {
<FilterOptions id={'role'} options={filters.role} selected={role} onSelect={onFilterSelect} />
</div>
</div>
{userCan.usersNeedsToBeApproved ? (
<div className="mi-filter">
<div className="mi-filter-title">APPROVED</div>
<div className="mi-filter-options">
<FilterOptions id={'approved'} options={filters.approved} selected={approved} onSelect={onFilterSelect} />
</div>
</div>
) : null}
</div>
</div>
);

View File

@@ -102,7 +102,11 @@ export function SidebarNavigationMenu() {
});
}
if (PageStore.get('config-enabled').pages.members && PageStore.get('config-enabled').pages.members.enabled) {
if (
PageStore.get('config-enabled').pages.members &&
PageStore.get('config-enabled').pages.members.enabled &&
userCan.canSeeMembersPage
) {
items.push({
link: links.members,
icon: 'people',

View File

@@ -14,6 +14,8 @@ export function init(user, features) {
register: true,
addMedia: false,
editProfile: false,
canSeeMembersPage: true,
usersNeedsToBeApproved: true,
changePassword: true,
deleteProfile: false,
readComment: true,
@@ -91,6 +93,8 @@ export function init(user, features) {
}
}
MEMBER.can.canSeeMembersPage = true === user.can.canSeeMembersPage;
MEMBER.can.usersNeedsToBeApproved = true === user.can.usersNeedsToBeApproved;
MEMBER.can.addMedia = true === user.can.addMedia;
MEMBER.can.editProfile = true === user.can.editProfile;
MEMBER.can.readComment = false === user.can.readComment ? false : true;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,23 @@
{% block innercontent %}
{% include "cms/media_nav.html" with active_tab="subtitles" %}
{% if whisper_form %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h3 style="display: flex; align-items: center; gap: 0.25rem;">Request Automatic Transcription and Translation
<span title="This is Automatic Transcription using a Whisper model that is loaded locally" style="cursor: help;">
<i class="material-icons">info_outline</i>
</span>
</h3>
<form enctype="multipart/form-data" action="" method="post" class="post-form">
{% csrf_token %}
{% crispy whisper_form %}
</form>
</div>
</div>
{% endif %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<form enctype="multipart/form-data" action="" method="post" class="post-form">
@@ -20,7 +37,7 @@
{% if subtitles %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h3>Existing Subtitles</h3>
<h3>Edit existing Captions</h3>
<ul>
{% for subtitle in subtitles %}
<li><a href="{{subtitle.url}}">{{subtitle.language.title}}</a></li>
@@ -30,20 +47,5 @@
</div>
{% endif %}
{% if whisper_form %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h3 style="display: flex; align-items: center; gap: 0.25rem;">Request Automatic Tranascription
<span title="This is Automatic Transcription using a Whisper model that is loaded locally" style="cursor: help;">
<i class="material-icons">info_outline</i>
</span>
</h3>
<form enctype="multipart/form-data" action="" method="post" class="post-form">
{% csrf_token %}
{% crispy whisper_form %}
</form>
</div>
</div>
{% endif %}
{% endblock innercontent %}

View File

@@ -59,24 +59,43 @@
</style>
<div class="form-group{% if field.errors %} has-error{% endif %}">
<div class="control-label-container">
{% if field.label %}
<label for="{{ field.id_for_label }}" class="control-label">
{{ field.label }}
{% if field.field.widget.input_type == 'checkbox' and field.field.choices|length <= 1 %}
<div class="controls">
<label for="{{ field.id_for_label }}" class="control-label" style="font-weight: normal;">
{% crispy_field field %}
<span style="margin-left: 5px;">{{ field.label }}</span>
{% if field.help_text %}
<span class="help-text-inline">- {{ field.help_text|safe }}</span>
{% endif %}
</label>
{% endif %}
</div>
<div class="controls {% if field.name == 'title' or field.name == 'new_tags' or field.name == 'description' %}full-width{% endif %}">
{% crispy_field field %}
{% if field.errors %}
<div class="error-container">
{% for error in field.errors %}
<p class="invalid-feedback">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if field.errors %}
<div class="error-container">
{% for error in field.errors %}
<p class="invalid-feedback">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="control-label-container">
{% if field.label %}
<label for="{{ field.id_for_label }}" class="control-label">
{{ field.label }}
{% if field.help_text %}
<span class="help-text-inline">- {{ field.help_text|safe }}</span>
{% endif %}
</label>
{% endif %}
</div>
<div class="controls {% if field.name == 'title' or field.name == 'new_tags' or field.name == 'description' %}full-width{% endif %}">
{% crispy_field field %}
{% if field.errors %}
<div class="error-container">
{% for error in field.errors %}
<p class="invalid-feedback">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -28,7 +28,7 @@
{% else %}
<div class="user-action-form-wrap">
<h1>Edit {{subtitle.language.title}} subtitle</h1>
<h1>{{subtitle.language.title}}</h1>
<div class="user-action-form-inner">
Media: <a href="{{subtitle.media.get_absolute_url}}">{{subtitle.media.title}}</a>
<form action="" method="post" class="post-form">

View File

@@ -15,7 +15,7 @@
<li style="display: inline-block;">
<a href="{% url 'add_subtitle' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'subtitles' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
{{ "Subtitles" | custom_translate:LANGUAGE_CODE}}
{{ "Captions" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
<li style="display: inline-block;">

View File

@@ -16,6 +16,7 @@
<div style="text-align: center; padding: 40px 0;">
<p style="margin-bottom: 20px;">{{ "Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded." | custom_translate:LANGUAGE_CODE}}</p>
<p style="margin-bottom: 20px;">{{ "This works in Chrome, Safari and Edge browsers." | custom_translate:LANGUAGE_CODE}}</p>
<button id="startBtn" class="qq-upload-button-selector" style="padding: 10px 20px; font-size: 16px; margin-right: 10px; cursor: pointer;">{{ "Start Recording" | custom_translate:LANGUAGE_CODE}}</button>
<button id="stopBtn" class="qq-upload-button-selector" disabled style="padding: 10px 20px; font-size: 16px; cursor: pointer;">{{ "Stop Recording" | custom_translate:LANGUAGE_CODE}}</button>
<div id="spinner" style="display: none; margin-top: 20px;">
@@ -81,7 +82,8 @@ document.addEventListener("DOMContentLoaded", function(event) {
const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream = new MediaStream([...displayStream.getTracks(), ...audioStream.getTracks()]);
audioStream.getAudioTracks().forEach(track => displayStream.addTrack(track));
stream = displayStream;
}
// When user stops sharing screen via browser UI

View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block headtitle %} | Account needs approval{% endblock headtitle %}
{% block innercontent %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h1>Account Pending Approval</h1>
<p>Your account is currently pending approval from an administrator.</p>
<p><a href="{% url 'account_logout' %}">Logout</a></p>
</div>
</div>
{% endblock innercontent %}

View File

@@ -16,9 +16,11 @@ MediaCMS.user = {
deleteComment: {% if CAN_DELETE_COMMENTS %}true{% else %}false{% endif %},
editProfile: {% if CAN_EDIT %}true{% else %}false{% endif %},
deleteProfile: {% if CAN_DELETE %}true{% else %}false{% endif %},
canSeeMembersPage: {% if CAN_SEE_MEMBERS_PAGE %}true{% else %}false{% endif %},
manageMedia: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER or IS_MEDIACMS_EDITOR %}true{% else %}false{% endif %},
manageUsers: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER %}true{% else %}false{% endif %},
manageComments: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER or IS_MEDIACMS_EDITOR %}true{% else %}false{% endif %},
usersNeedsToBeApproved: {% if USERS_NEEDS_TO_BE_APPROVED %}true{% else %}false{% endif %},
},
pages: {
media: '/user/{{request.user.username}}',

View File

@@ -1,26 +1,26 @@
MediaCMS.contents = {
sidebar: {
navMenuItems: [{
text: "About",
link: "/about",
icon: 'contact_support',
},
{
text: "Terms",
link: "/tos",
icon: 'insert_drive_file',
},
{
text: "Contact",
link: "/contact",
icon: 'alternate_email',
}
],
belowNavMenu: null,
footer: 'Powered by <a href="//mediacms.io" title="mediacms.io" target="_blank">mediacms.io</a>',
},
uploader: {
belowUploadArea: "{{PRE_UPLOAD_MEDIA_MESSAGE}}",
postUploadMessage: "{{POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY}}",
},
};
MediaCMS.contents = {
sidebar: {
navMenuItems: [{
text: "About",
link: "/about",
icon: 'contact_support',
},
{
text: "Terms",
link: "/tos",
icon: 'insert_drive_file',
},
{
text: "Contact",
link: "/contact",
icon: 'alternate_email',
}
],
belowNavMenu: null,
footer: {% if SIDEBAR_FOOTER_TEXT %}'{{ SIDEBAR_FOOTER_TEXT|escapejs }}'{% else %}'Powered by <a href="//mediacms.io" title="mediacms.io" target="_blank">mediacms.io</a>'{% endif %},
},
uploader: {
belowUploadArea: "{{PRE_UPLOAD_MEDIA_MESSAGE}}",
postUploadMessage: "{{POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY}}",
},
};

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin
from .models import User
@@ -5,20 +6,7 @@ from .models import User
class UserAdmin(admin.ModelAdmin):
search_fields = ["email", "username", "name"]
exclude = (
"user_permissions",
"title",
"password",
"groups",
"last_login",
"is_featured",
"location",
"first_name",
"last_name",
"media_count",
"date_joined",
"is_active",
)
exclude = ["user_permissions", "title", "password", "groups", "last_login", "is_featured", "location", "first_name", "last_name", "media_count", "date_joined", "is_active", "is_approved"]
list_display = [
"username",
"name",
@@ -33,5 +21,10 @@ class UserAdmin(admin.ModelAdmin):
list_filter = ["is_superuser", "is_editor", "is_manager"]
ordering = ("-date_added",)
if settings.USERS_NEEDS_TO_BE_APPROVED:
list_display.append("is_approved")
list_filter.append("is_approved")
exclude.remove("is_approved")
admin.site.register(User, UserAdmin)

View File

@@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from files.methods import is_mediacms_manager
@@ -25,6 +26,7 @@ class UserForm(forms.ModelForm):
"advancedUser",
"is_manager",
"is_editor",
"is_approved",
# "allow_contact",
)
@@ -44,6 +46,11 @@ class UserForm(forms.ModelForm):
self.fields.pop("advancedUser")
self.fields.pop("is_manager")
self.fields.pop("is_editor")
if not settings.USERS_NEEDS_TO_BE_APPROVED or not is_mediacms_manager(user):
if "is_approved" in self.fields:
self.fields.pop("is_approved")
if user.socialaccount_set.exists():
# for Social Accounts do not allow to edit the name
self.fields["name"].widget.attrs['readonly'] = True

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-09-19 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_approved',
field=models.BooleanField(blank=True, db_index=True, default=False, null=True, verbose_name='Is approved'),
),
]

View File

@@ -29,6 +29,7 @@ class User(AbstractUser):
name = models.CharField("full name", max_length=250, db_index=True)
date_added = models.DateTimeField("date added", default=timezone.now, db_index=True)
is_featured = models.BooleanField("Is featured", default=False, db_index=True)
is_approved = models.BooleanField("Is approved", default=False, null=True, blank=True, db_index=True)
title = models.CharField("Title", max_length=250, blank=True)
advancedUser = models.BooleanField("advanced user", default=False, db_index=True)

View File

@@ -22,7 +22,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
read_only_fields = (
read_only_fields = [
"date_added",
"is_featured",
"uid",
@@ -31,8 +31,8 @@ class UserSerializer(serializers.ModelSerializer):
"is_editor",
"is_manager",
"email_is_verified",
)
fields = (
]
fields = [
"description",
"date_added",
"name",
@@ -45,7 +45,11 @@ class UserSerializer(serializers.ModelSerializer):
"is_editor",
"is_manager",
"email_is_verified",
)
]
if settings.USERS_NEEDS_TO_BE_APPROVED:
fields.append("is_approved")
read_only_fields.append("is_approved")
class UserDetailSerializer(serializers.ModelSerializer):

View File

@@ -205,6 +205,12 @@ class UserList(APIView):
operation_description='Paginated listing of users',
)
def get(self, request, format=None):
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
raise PermissionDenied("You do not have permission to view this page.")
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
raise PermissionDenied("You do not have permission to view this page.")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
users = User.objects.filter()
@@ -213,11 +219,57 @@ class UserList(APIView):
if name:
users = users.filter(Q(name__icontains=name) | Q(username__icontains=name))
if settings.USERS_NEEDS_TO_BE_APPROVED:
is_approved = request.GET.get("is_approved")
if is_approved == "true":
users = users.filter(is_approved=True)
elif is_approved == "false":
users = users.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
page = paginator.paginate_queryset(users, request)
serializer = UserSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["username", "password", "email", "name"],
properties={
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(type=openapi.TYPE_STRING),
"email": openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_EMAIL),
"name": openapi.Schema(type=openapi.TYPE_STRING),
},
),
tags=["Users"],
operation_summary="Create user",
operation_description="Create a new user. Only for managers.",
responses={201: UserSerializer},
)
def post(self, request, format=None):
if not is_mediacms_manager(request.user):
raise PermissionDenied("You do not have permission to create users.")
username = request.data.get("username")
password = request.data.get("password")
email = request.data.get("email")
name = request.data.get("name")
if not all([username, password, email, name]):
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(email=email).exists():
return Response({"detail": "A user with that email already exists."}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.create_user(username=username, password=password, email=email, name=name)
serializer = UserSerializer(user, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
class UserDetail(APIView):
""""""
@@ -284,27 +336,36 @@ class UserDetail(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
manual_parameters=[
openapi.Parameter(name='action', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=True, description="action to perform ('change_password' or 'approve_user')"),
openapi.Parameter(name='password', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="new password (if action is 'change_password')"),
],
tags=['Users'],
operation_summary='Xto_be_written',
operation_description='to_be_written',
operation_summary='Update user details',
operation_description='Allows a user to change their password. Allows a manager to approve a user.',
)
def put(self, request, uid, format=None):
# ADMIN
user = self.get_user(uid)
def put(self, request, username, format=None):
user = self.get_user(username)
if isinstance(user, Response):
return user
if not request.user.is_superuser:
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("action")
if action == "feature":
user.is_featured = True
if action == "change_password":
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions
password = request.data.get("password")
if not password:
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(password)
user.save()
elif action == "unfeature":
user.is_featured = False
elif action == "approve_user":
if not is_mediacms_manager(request.user):
raise PermissionDenied("You do not have permission to approve users.")
user.is_approved = True
user.save()
else:
return Response({"detail": "Invalid action"}, status=status.HTTP_400_BAD_REQUEST)
serializer = UserDetailSerializer(user, context={"request": request})
return Response(serializer.data)