feat: RBAC + SAML support

This commit is contained in:
Markos Gogoulos 2025-04-05 12:44:21 +03:00 committed by GitHub
parent 8fecccce1c
commit 05414f66c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
158 changed files with 6423 additions and 106 deletions

View File

@ -37,7 +37,7 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install runtime system dependencies
RUN apt-get update -y && \
apt-get -y upgrade && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps -y && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge --auto-remove && \
apt-get clean
@ -85,4 +85,4 @@ EXPOSE 9000 80
RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"]
CMD ["./deploy/docker/start.sh"]

View File

@ -23,11 +23,13 @@ A demo is available at https://demo.mediacms.io
## Features
- **Complete control over your data**: host it yourself!
- **Support for multiple publishing workflows**: public, private, unlisted and custom
- **Modern technologies**: Django/Python/Celery, React.
- **Support for multiple publishing workflows**: public, private, unlisted and custom
- **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
- **Role-Based Access Control (RBAC)
- **SAML support
- **Easy media searching**: enriched with live search functionality
- **Playlists for audio and video content**: create playlists, add and reorder content
- **Responsive design**: including light and dark themes

View File

View File

View File

@ -0,0 +1,86 @@
from django.apps import AppConfig
from django.conf import settings
from django.contrib import admin
class AdminCustomizationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_customizations'
def ready(self):
original_get_app_list = admin.AdminSite.get_app_list
def get_app_list(self, request, app_label=None):
"""Custom get_app_list"""
app_list = original_get_app_list(self, request, app_label)
# To see the list:
# print([a.get('app_label') for a in app_list])
email_model = None
rbac_group_model = None
identity_providers_user_log_model = None
identity_providers_login_option = None
auth_app = None
rbac_app = None
socialaccount_app = None
for app in app_list:
if app['app_label'] == 'users':
auth_app = app
elif app['app_label'] == 'account':
for model in app['models']:
if model['object_name'] == 'EmailAddress':
email_model = model
elif app['app_label'] == 'rbac':
if not getattr(settings, 'USE_RBAC', False):
continue
rbac_app = app
for model in app['models']:
if model['object_name'] == 'RBACGroup':
rbac_group_model = model
elif app['app_label'] == 'identity_providers':
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
continue
models_to_check = list(app['models'])
for model in models_to_check:
if model['object_name'] == 'IdentityProviderUserLog':
identity_providers_user_log_model = model
if model['object_name'] == 'LoginOption':
identity_providers_login_option = model
elif app['app_label'] == 'socialaccount':
socialaccount_app = app
if email_model and auth_app:
auth_app['models'].append(email_model)
if rbac_group_model and rbac_app and auth_app:
auth_app['models'].append(rbac_group_model)
if identity_providers_login_option and socialaccount_app:
socialaccount_app['models'].append(identity_providers_login_option)
if identity_providers_user_log_model and socialaccount_app:
socialaccount_app['models'].append(identity_providers_user_log_model)
# 2. don't include the following apps
apps_to_hide = ['authtoken', 'auth', 'account', 'saml_auth', 'rbac']
if not getattr(settings, 'USE_RBAC', False):
apps_to_hide.append('rbac')
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
apps_to_hide.append('socialaccount')
app_list = [app for app in app_list if app['app_label'] not in apps_to_hide]
# 3. change the ordering
app_order = {
'files': 1,
'users': 2,
'socialaccount': 3,
'rbac': 5,
}
app_list.sort(key=lambda x: app_order.get(x['app_label'], 999))
return app_list
admin.AdminSite.get_app_list = get_app_list

View File

View File

View File

View File

@ -4,30 +4,36 @@ import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'rest_framework',
'rest_framework.authtoken',
'imagekit',
'files.apps.FilesConfig',
'users.apps.UsersConfig',
'actions.apps.ActionsConfig',
'debug_toolbar',
'mptt',
'crispy_forms',
"admin_customizations",
"django.contrib.auth",
"allauth",
"allauth.account",
"allauth.socialaccount",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"jazzmin",
"django.contrib.admin",
"django.contrib.sites",
"rest_framework",
"rest_framework.authtoken",
"imagekit",
"files.apps.FilesConfig",
"users.apps.UsersConfig",
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
"crispy_bootstrap5",
'uploader.apps.UploaderConfig',
'djcelery_email',
'drf_yasg',
'corsheaders',
"uploader.apps.UploaderConfig",
"djcelery_email",
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
"corsheaders",
]
MIDDLEWARE = [

View File

@ -115,7 +115,7 @@ ACCOUNT_LOGIN_METHODS = {"username", "email"}
ACCOUNT_EMAIL_REQUIRED = True # new users need to specify email
ACCOUNT_EMAIL_VERIFICATION = "optional" # 'mandatory' 'none'
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_USERNAME_MIN_LENGTH = "4"
ACCOUNT_USERNAME_MIN_LENGTH = 4
ACCOUNT_ADAPTER = "users.adapter.MyAccountAdapter"
ACCOUNT_SIGNUP_FORM_CLASS = "users.forms.SignupForm"
ACCOUNT_USERNAME_VALIDATORS = "users.validators.custom_username_validators"
@ -256,7 +256,7 @@ AUTHENTICATION_BACKENDS = (
)
INSTALLED_APPS = [
"django.contrib.admin",
"admin_customizations",
"django.contrib.auth",
"allauth",
"allauth.account",
@ -265,6 +265,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"jazzmin",
"django.contrib.admin",
"django.contrib.sites",
"rest_framework",
"rest_framework.authtoken",
@ -272,6 +274,8 @@ INSTALLED_APPS = [
"files.apps.FilesConfig",
"users.apps.UsersConfig",
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
@ -279,6 +283,8 @@ INSTALLED_APPS = [
"uploader.apps.UploaderConfig",
"djcelery_email",
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
]
MIDDLEWARE = [
@ -440,26 +446,6 @@ if os.environ.get("TESTING"):
CELERY_TASK_ALWAYS_EAGER = True
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
# ALLOWED_HOSTS needs a url/ip
ALLOWED_HOSTS.append(FRONTEND_HOST.replace("http://", "").replace("https://", ""))
except ImportError:
# local_settings not in use
pass
if "http" not in FRONTEND_HOST:
# FRONTEND_HOST needs a http:// preffix
FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa
if LOCAL_INSTALL:
SSL_FRONTEND_HOST = FRONTEND_HOST.replace("http", "https")
else:
SSL_FRONTEND_HOST = FRONTEND_HOST
if GLOBAL_LOGIN_REQUIRED:
# this should go after the AuthenticationMiddleware middleware
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware")
@ -477,16 +463,6 @@ DO_NOT_TRANSCODE_VIDEO = False
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# the following is related to local development using docker
# and docker-compose-dev.yaml
try:
DEVELOPMENT_MODE = os.environ.get("DEVELOPMENT_MODE")
if DEVELOPMENT_MODE:
# keep a dev_settings.py file for local overrides
from .dev_settings import * # noqa
except ImportError:
pass
LANGUAGES = [
('ar', _('Arabic')),
('bn', _('Bengali')),
@ -526,7 +502,45 @@ CRISPY_TEMPLATE_PACK = "bootstrap5"
# keep the trailing slash
DJANGO_ADMIN_URL = "admin/"
# this are used around a number of places and will need to be well documented!!!
USE_SAML = False
USE_RBAC = False
USE_IDENTITY_PROVIDERS = False
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
# ALLOWED_HOSTS needs a url/ip
ALLOWED_HOSTS.append(FRONTEND_HOST.replace("http://", "").replace("https://", ""))
except ImportError:
# local_settings not in use
pass
if "http" not in FRONTEND_HOST:
# FRONTEND_HOST needs a http:// preffix
FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa
if LOCAL_INSTALL:
SSL_FRONTEND_HOST = FRONTEND_HOST.replace("http", "https")
else:
SSL_FRONTEND_HOST = FRONTEND_HOST
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True
PYSUBS_COMMAND = "pysubs2"
# the following is related to local development using docker
# and docker-compose-dev.yaml
try:
DEVELOPMENT_MODE = os.environ.get("DEVELOPMENT_MODE")
if DEVELOPMENT_MODE:
# keep a dev_settings.py file for local overrides
from .dev_settings import * # noqa
except ImportError:
pass

View File

@ -31,3 +31,7 @@ urlpatterns = [
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]
admin.site.site_header = "MediaCMS Admin"
admin.site.site_title = "MediaCMS"
admin.site.index_title = "Admin"

1
cms/version.py Normal file
View File

@ -0,0 +1 @@
VERSION = "5.0.0"

View File

@ -1,5 +0,0 @@
from pytest_factoryboy import register
from tests.users.factories import UserFactory
register(UserFactory)

75
deic_setup_notes.md Normal file
View File

@ -0,0 +1,75 @@
# MediaCMS: Document Changes for DEIC
## Configuration Changes
The following changes are required in `deploy/docker/local_settings.py`:
```python
# default workflow
PORTAL_WORKFLOW = 'private'
# Authentication Settings
# these two are necessary so that users cannot register through system accounts. They can only register through identity providers
REGISTER_ALLOWED = False
USERS_CAN_SELF_REGISTER = False
USE_RBAC = True
USE_SAML = True
USE_IDENTITY_PROVIDERS = True
# Proxy and SSL Settings
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
# SAML Configuration
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
ACCOUNT_USERNAME_VALIDATORS = "users.validators.less_restrictive_username_validators"
SOCIALACCOUNT_PROVIDERS = {
"saml": {
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
}
}
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_EMAIL_REQUIRED = False
# if set to strict, user is created with the email from the saml provider without
# checking if the email is already on the system
# however if this is ommited, and user tries to login with an email that already exists on
# the system, then they get to the ugly form where it suggests they add a username/email/name
ACCOUNT_PREVENT_ENUMERATION = 'strict'
```
## SAML Configuration Steps
### Step 1: Add SAML Identity Provider
1. Navigate to Admin panel
2. Select "Identity Provider"
3. Configure as follows:
- **Provider**: saml # ensure this is set with lower case!
- **Provider ID**: `wayf.wayf.dk`
- **IDP Config Name**: `Deic` (or preferred name)
- **Client ID**: `wayf_dk` (important: defines the URL, e.g., `https://deic.mediacms.io/accounts/saml/wayf_dk`)
- **Site**: Set the default one
### Step 2: Add SAML Configuration
Can be set through the SAML Configurations tab:
1. **IDP ID**: Must be a URL, e.g., `https://wayf.wayf.dk`
2. **IDP Certificate**: x509cert from your SAML provider
3. **SSO URL**: `https://wayf.wayf.dk/saml2/idp/SSOService2.php`
4. **SLO URL**: `https://wayf.wayf.dk/saml2/idp/SingleLogoutService.php`
5. **SP Metadata URL**: The metadata URL set for the SP, e.g., `https://deic.mediacms.io/saml/metadata`. This should point to the URL of the SP and is autogenerated
### Step 3: Set the other Options
1. **Email Settings**:
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
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

View File

@ -13,7 +13,7 @@ services:
ENABLE_CELERY_BEAT: 'no'
ADMIN_USER: 'admin'
ADMIN_EMAIL: 'admin@localhost'
#ADMIN_PASSWORD: 'uncomment_and_set_password_here'
# ADMIN_PASSWORD: 'uncomment_and_set_password_here'
command: "./deploy/docker/prestart.sh"
restart: on-failure
depends_on:

View File

@ -21,7 +21,12 @@
- [18. Disable encoding and show only original file](#18-disable-encoding-and-show-only-original-file)
- [19. Rounded corners on videos](#19-rounded-corners)
- [20. Translations](#20-translations)
- [21. How to change the video frames on videos](#21-fames)
- [21. How to change the video frames on videos](#21-how-to-change-the-video-frames-on-videos)
- [22. Role-Based Access Control](#22-role-based-access-control)
- [23. SAML setup](#23-saml-setup)
- [24. Identity Providers setup](#24-identity-providers-setup)
## 1. Welcome
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
@ -861,3 +866,110 @@ By default while watching a video you can hover and see the small images named s
After that, newly uploaded videos will have sprites generated with the new number of seconds.
## 22. Role-Based Access Control
By default there are 3 statuses for any Media that lives on the system, public, unlisted, private. When RBAC support is added, a user that is part of a group has access to media that are published to one or more categories that the group is associated with. The workflow is this:
1. A Group is created
2. A Category is associated with the Group
3. A User is added to the Group
Now user can view the Media even if it is in private state. User also sees all media in Category page
When user is added to group, they can be set as Member, Contributor, Manager.
- Member: user can view media that are published on one or more categories that this group is associated with
- Contributor: besides viewing, user can also edit the Media in a category associated with this Group. They can also publish Media to this category
- Manager: same as Contributor for now
Use cases facilitated with RBAC:
- viewing a Media in private state: if RBAC is enabled, if user is Member on a Group that is associated with a Category, and the media is published to this Category, then user can view the media
- editing a Media: if RBAC is enabled, and user is Contributor to one or more Categories, they can publish media to these Categories as long as they are associated with one Group
- viewing all media of a category: if RBAC is enabled, and user visits a Category, they are able to see the listing of all media that are published in this category, independent of their state, provided that the category is associated with a group that the user is member of
- viewing all categories associated with groups the user is member of: if RBAC is enabled, and user visits the listing of categories, they can view all categories that are associated with a group the user is member
How to enable RBAC support:
```
USE_RBAC = True
```
on `local_settings.py` and restart the instance.
## 23. SAML setup
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
To enable SAML support, edit local_settings.py and set the following options:
```
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",
}
}
```
To set a SAML provider:
- Step 1: Add SAML Identity Provider
1. Navigate to Admin panel
2. Select "Identity Provider"
3. Configure as follows:
- **Provider**: saml
- **Provider ID**: an ID for the provider
- **IDP Config Name**: a name for the provider
- **Client ID**: the identifier that is part of the login, and that is shared with the IDP.
- **Site**: Set the default one
- Step 2: Add SAML Configuration
Select the SAML Configurations tab, create a new one and set:
1. **IDP ID**: Must be a URL
2. **IDP Certificate**: x509cert from your SAML provider
3. **SSO URL**:
4. **SLO URL**:
5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS
- Step 3: Set other Options
1. **Email Settings**:
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
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
## 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:
- allows to add an Identity Provider through Django admin, and set a number of mappings, as Group Mapping, Global Role mapping and more. While SAML is the only provider that can be added out of the box, any identity provider supported by django allauth can be added with minimal effort. If the response of the identity provider contains attributes as role, or groups, then these can be mapped to MediaCMS specific roles (advanced user, editor, manager, admin) and groups (rbac groups)
- saves SAML response logs after user is authenticated (can be utilized for other providers too)
- allows to specify a list of login options through the admin (eg system login, identity provider login)
to enable the identity providers, set the following setting on `local_settings.py`:
```
USE_IDENTITY_PROVIDERS = True
```
Visiting the admin, you will see the Identity Providers tab and you can add one.

View File

@ -1,4 +1,10 @@
from django import forms
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import transaction
from rbac.models import RBACGroup
from .models import (
Category,
@ -49,12 +55,126 @@ class MediaAdmin(admin.ModelAdmin):
get_comments_count.short_description = "Comments count"
class CategoryAdminForm(forms.ModelForm):
rbac_groups = forms.ModelMultipleChoiceField(queryset=RBACGroup.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Groups', False))
class Meta:
model = Category
fields = '__all__'
def clean(self):
cleaned_data = super().clean()
is_rbac_category = cleaned_data.get('is_rbac_category')
identity_provider = cleaned_data.get('identity_provider')
# Check if this category has any RBAC groups
if self.instance.pk:
has_rbac_groups = cleaned_data.get('rbac_groups')
else:
has_rbac_groups = False
if not is_rbac_category:
if has_rbac_groups:
cleaned_data['is_rbac_category'] = True
# self.add_error('is_rbac_category', ValidationError('This category has RBAC groups assigned. "Is RBAC Category" must be enabled.'))
for rbac_group in cleaned_data.get('rbac_groups'):
if rbac_group.identity_provider != identity_provider:
self.add_error('rbac_groups', ValidationError('Chosen Groups are associated with a different Identity Provider than the one selected here.'))
return cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['rbac_groups'].initial = self.instance.rbac_groups.all()
def save(self, commit=True):
category = super().save(commit=True)
if commit:
self.save_m2m()
if self.instance.rbac_groups.exists() or self.cleaned_data.get('rbac_groups'):
if not self.cleaned_data['is_rbac_category']:
category.is_rbac_category = True
category.save(update_fields=['is_rbac_category'])
return category
@transaction.atomic
def save_m2m(self):
if self.instance.pk:
rbac_groups = self.cleaned_data['rbac_groups']
self._update_rbac_groups(rbac_groups)
def _update_rbac_groups(self, rbac_groups):
new_rbac_group_ids = RBACGroup.objects.filter(pk__in=rbac_groups).values_list('pk', flat=True)
existing_rbac_groups = RBACGroup.objects.filter(categories=self.instance)
existing_rbac_groups_ids = existing_rbac_groups.values_list('pk', flat=True)
rbac_groups_to_add = RBACGroup.objects.filter(pk__in=new_rbac_group_ids).exclude(pk__in=existing_rbac_groups_ids)
rbac_groups_to_remove = existing_rbac_groups.exclude(pk__in=new_rbac_group_ids)
for rbac_group in rbac_groups_to_add:
rbac_group.categories.add(self.instance)
for rbac_group in rbac_groups_to_remove:
rbac_group.categories.remove(self.instance)
class CategoryAdmin(admin.ModelAdmin):
search_fields = ["title"]
list_display = ["title", "user", "add_date", "is_global", "media_count"]
list_filter = ["is_global"]
form = CategoryAdminForm
search_fields = ["title", "uid"]
list_display = ["title", "user", "add_date", "media_count"]
list_filter = []
ordering = ("-add_date",)
readonly_fields = ("user", "media_count")
change_form_template = 'admin/files/category/change_form.html'
def get_list_filter(self, request):
list_filter = list(self.list_filter)
if getattr(settings, 'USE_RBAC', False):
list_filter.insert(0, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_filter.insert(-1, "identity_provider")
return list_filter
def get_list_display(self, request):
list_display = list(self.list_display)
if getattr(settings, 'USE_RBAC', False):
list_display.insert(-1, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_display.insert(-1, "identity_provider")
return list_display
def get_fieldsets(self, request, obj=None):
basic_fieldset = [
(
'Category Information',
{
'fields': ['uid', 'title', 'description', 'user', 'media_count', 'thumbnail', 'listings_thumbnail'],
},
),
]
if getattr(settings, 'USE_RBAC', False):
rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
]
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
]
return basic_fieldset + rbac_fieldset
else:
return basic_fieldset
class TagAdmin(admin.ModelAdmin):
@ -102,3 +222,5 @@ admin.site.register(Category, CategoryAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Subtitle, SubtitleAdmin)
admin.site.register(Language, LanguageAdmin)
Media._meta.app_config.verbose_name = "Media"

View File

@ -34,6 +34,7 @@ def stuff(request):
ret["RSS_URL"] = "/rss"
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
ret["USE_SAML"] = settings.USE_SAML
if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL

View File

@ -1,7 +1,8 @@
from django import forms
from django.conf import settings
from .methods import get_next_state, is_mediacms_editor
from .models import Media, Subtitle
from .models import Category, Media, Subtitle
class MultipleSelect(forms.CheckboxSelectMultiple):
@ -41,6 +42,25 @@ class MediaForm(forms.ModelForm):
self.fields.pop("featured")
self.fields.pop("reported_times")
self.fields.pop("is_reviewed")
# if settings.PORTAL_WORKFLOW == 'private':
# self.fields.pop("state")
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user):
pass
else:
self.fields['category'].initial = self.instance.category.all()
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
rbac_categories = user.get_rbac_categories_as_contributor()
combined_category_ids = list(non_rbac_categories.values_list('id', flat=True)) + list(rbac_categories.values_list('id', flat=True))
if self.instance.pk:
instance_category_ids = list(self.instance.category.all().values_list('id', flat=True))
combined_category_ids = list(set(combined_category_ids + instance_category_ids))
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
def clean_uploaded_poster(self):

View File

@ -0,0 +1,41 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0003_auto_20210927_1245'),
('socialaccount', '0006_alter_socialaccount_extra_data'),
]
operations = [
migrations.AlterModelOptions(
name='subtitle',
options={'ordering': ['language__title']},
),
migrations.AddField(
model_name='category',
name='identity_provider',
field=models.ForeignKey(
blank=True,
help_text='If category is related with a specific Identity Provider',
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='categories',
to='socialaccount.socialapp',
verbose_name='IDP Config Name',
),
),
migrations.AddField(
model_name='category',
name='is_rbac_category',
field=models.BooleanField(db_index=True, default=False, help_text='If access to Category is controlled by role based membership of Groups'),
),
migrations.AlterField(
model_name='media',
name='state',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='private', help_text='state of Media', max_length=20),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 14:13
from django.db import migrations, models
import files.models
class Migration(migrations.Migration):
dependencies = [
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
]
operations = [
migrations.AlterField(
model_name='category',
name='uid',
field=models.CharField(default=files.models.generate_uid, max_length=36, unique=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-03-27 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0005_alter_category_uid'),
]
operations = [
migrations.AlterField(
model_name='category',
name='title',
field=models.CharField(db_index=True, max_length=100),
),
]

View File

@ -18,6 +18,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import strip_tags
from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFit
@ -83,6 +84,10 @@ 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 = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
@ -957,11 +962,11 @@ class License(models.Model):
class Category(models.Model):
"""A Category base model"""
uid = models.UUIDField(unique=True, default=uuid.uuid4)
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, unique=True, db_index=True)
title = models.CharField(max_length=100, db_index=True)
description = models.TextField(blank=True)
@ -981,6 +986,18 @@ class Category(models.Model):
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
@ -994,7 +1011,11 @@ class Category(models.Model):
def update_category_media(self):
"""Set media_count"""
self.media_count = Media.objects.filter(listable=True, category=self).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

View File

@ -1,5 +1,7 @@
from django.conf import settings
from rest_framework import serializers
from .methods import is_mediacms_editor
from .models import Category, Comment, EncodeProfile, Media, Playlist, Tag
# TODO: put them in a more DRY way
@ -76,8 +78,25 @@ class MediaSerializer(serializers.ModelSerializer):
"featured",
"user_featured",
"size",
# "category",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get('request')
if False and request and 'category' in self.fields:
# this is not working
user = request.user
if is_mediacms_editor(user):
pass
else:
if getattr(settings, 'USE_RBAC', False):
# Filter category queryset based on user permissions
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
rbac_categories = user.get_rbac_categories_as_contributor()
self.fields['category'].queryset = non_rbac_categories.union(rbac_categories)
class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username")

View File

@ -1,3 +1,4 @@
from allauth.account.views import LoginView
from django.conf import settings
from django.conf.urls import include
from django.conf.urls.static import static
@ -93,5 +94,14 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
if hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS:
urlpatterns.append(path('accounts/login_system', LoginView.as_view(), name='login_system'))
urlpatterns.append(re_path(r"^accounts/login", views.custom_login_view, name='login'))
else:
urlpatterns.append(path('accounts/login', LoginView.as_view(), name='login_system'))
if hasattr(settings, "GENERATE_SITEMAP") and settings.GENERATE_SITEMAP:
urlpatterns.append(path("sitemap.xml", views.sitemap, name="sitemap"))

View File

@ -1,13 +1,15 @@
from datetime import datetime, timedelta
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery
from django.core.mail import EmailMessage
from django.db.models import Q
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
@ -30,6 +32,8 @@ from cms.permissions import (
IsUserOrEditor,
user_allowed_to_upload,
)
from cms.version import VERSION
from identity_providers.models import LoginOption
from users.models import User
from .forms import ContactForm, EditSubtitleForm, MediaForm, SubtitleForm
@ -77,7 +81,7 @@ VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
def about(request):
"""About view"""
context = {}
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
@ -387,6 +391,7 @@ def tos(request):
return render(request, "cms/tos.html", context)
@login_required
def upload_media(request):
"""Upload media view"""
@ -535,9 +540,10 @@ class MediaDetail(APIView):
# this need be explicitly called, and will call
# has_object_permission() after has_permission has succeeded
self.check_object_permissions(self.request, media)
if media.state == "private" and not (self.request.user == media.user or is_mediacms_editor(self.request.user)):
if (not password) or (not media.password) or (password != media.password):
if getattr(settings, 'USE_RBAC', False) and self.request.user.is_authenticated and self.request.user.has_member_access_to_media(media):
pass
elif (not password) or (not media.password) or (password != media.password):
return Response(
{"detail": "media is private"},
status=status.HTTP_401_UNAUTHORIZED,
@ -812,7 +818,7 @@ class MediaActions(APIView):
class MediaSearch(APIView):
"""
Retrieve results for searc
Retrieve results for search
Only GET is implemented here
"""
@ -872,6 +878,11 @@ class MediaSearch(APIView):
if category:
media = media.filter(category__title__contains=category)
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
c_object = Category.objects.filter(title=category, is_rbac_category=True).first()
if c_object and request.user.has_member_access_to_category(c_object):
# show all media where user has access based on RBAC
media = Media.objects.filter(category=c_object)
if media_type:
media = media.filter(media_type=media_type)
@ -1416,7 +1427,17 @@ class CategoryList(APIView):
},
)
def get(self, request, format=None):
categories = Category.objects.filter().order_by("title")
if is_mediacms_editor(request.user):
categories = Category.objects.filter()
else:
categories = Category.objects.filter(is_rbac_category=False)
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
rbac_categories = request.user.get_rbac_categories_as_member()
categories = categories.union(rbac_categories)
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
ret = serializer.data
return Response(ret)
@ -1484,3 +1505,38 @@ class TaskDetail(APIView):
# This is not imported!
# revoke(uid, terminate=True)
return Response(status=status.HTTP_204_NO_CONTENT)
def saml_metadata(request):
if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
raise Http404
xml_parts = ['<?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})

View File

@ -44,7 +44,7 @@ function headerPopupPages(user, popupNavItems, hasHeaderThemeSwitcher) {
<UserThumbnail size="medium" />
</span>
<span>
<span className="username">{user.username}</span>
<span className="username">{(user?.name || user?.email || user?.username || "User")}</span>
</span>
</a>
</PopupTop>

View File

360
identity_providers/admin.py Normal file
View File

@ -0,0 +1,360 @@
import csv
import logging
from allauth.socialaccount.admin import SocialAccountAdmin, SocialAppAdmin
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django import forms
from django.conf import settings
from django.contrib import admin
from identity_providers.forms import ImportCSVsForm
from identity_providers.models import (
IdentityProviderCategoryMapping,
IdentityProviderGlobalRole,
IdentityProviderGroupRole,
IdentityProviderUserLog,
LoginOption,
)
from rbac.models import RBACGroup
from saml_auth.models import SAMLConfiguration
class IdentityProviderUserLogAdmin(admin.ModelAdmin):
list_display = [
'identity_provider',
'user',
'created_at',
]
list_filter = ['identity_provider', 'created_at']
search_fields = ['identity_provider__name', 'user__username', 'user__email', 'logs']
readonly_fields = ['identity_provider', 'user', 'created_at', 'logs']
class SAMLConfigurationInline(admin.StackedInline):
model = SAMLConfiguration
extra = 0
can_delete = True
max_num = 1
class IdentityProviderCategoryMappingInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderCategoryMapping
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
class IdentityProviderCategoryMappingInline(admin.TabularInline):
model = IdentityProviderCategoryMapping
form = IdentityProviderCategoryMappingInlineForm
extra = 0
can_delete = True
show_change_link = True
verbose_name = "Category Mapping"
verbose_name_plural = "Category Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
autocomplete_fields = ['map_to']
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name', 'map_to') and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class RBACGroupInlineForm(forms.ModelForm):
class Meta:
model = RBACGroup
fields = ('uid', 'name')
labels = {
'uid': 'Group Attribute Value',
'name': 'Name',
}
help_texts = {
'uid': 'Identity Provider group attribute value',
'name': 'MediaCMS Group name',
}
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
class RBACGroupInline(admin.TabularInline):
model = RBACGroup
form = RBACGroupInlineForm
extra = 0
can_delete = True
show_change_link = True
verbose_name = "Group Mapping"
verbose_name_plural = "Group Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline_for_groups.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('uid', 'name') and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class CustomSocialAppAdmin(SocialAppAdmin):
# The default SocialAppAdmin has been overriden to achieve a number of changes.
# If you need to add more fields (out of those that are hidden), or remove tabs, or
# change the ordering of fields, or the place where fields appear, don't forget to
# check the html template!
change_form_template = 'admin/socialaccount/socialapp/change_form.html'
list_display = ('get_config_name', 'get_protocol')
fields = ('provider', 'provider_id', 'name', 'client_id', 'sites', 'groups_csv', 'categories_csv')
form = ImportCSVsForm
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.inlines = []
if getattr(settings, 'USE_SAML', False):
self.inlines.append(SAMLConfigurationInline)
self.inlines.append(IdentityProviderGlobalRoleInline)
self.inlines.append(IdentityProviderGroupRoleInline)
self.inlines.append(RBACGroupInline)
self.inlines.append(IdentityProviderCategoryMappingInline)
def get_protocol(self, obj):
return obj.provider
def get_config_name(self, obj):
return obj.name
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'provider':
field.label = 'Protocol'
field.help_text = "The provider type, eg `google`. For SAML providers, make sure this is set to `saml` lowercase."
elif db_field.name == 'name':
field.label = 'IDP Config Name'
field.help_text = "This should be a unique name for the provider."
elif db_field.name == 'client_id':
field.help_text = 'App ID, or consumer key. For SAML providers, this will be part of the default login URL /accounts/saml/{client_id}/login/'
elif db_field.name == 'sites':
field.required = True
field.help_text = "Select at least one site where this social application is available. Required."
elif db_field.name == 'provider_id':
field.required = True
field.help_text = "This should be a unique identifier for the provider."
return field
get_config_name.short_description = 'IDP Config Name'
get_protocol.short_description = 'Protocol'
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
csv_file = form.cleaned_data.get('groups_csv')
if csv_file:
try:
csv_file.seek(0)
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.DictReader(decoded_file)
for row in csv_reader:
group_id = row.get('group_id')
name = row.get('name')
if group_id and name:
if not (RBACGroup.objects.filter(identity_provider=obj, uid=group_id).exists() or RBACGroup.objects.filter(identity_provider=obj, name=name).exists()):
try:
group = RBACGroup.objects.create(identity_provider=obj, uid=group_id, name=name) # noqa
except Exception as e:
logging.error(e)
except Exception as e:
logging.error(e)
csv_file = form.cleaned_data.get('categories_csv')
if csv_file:
from files.models import Category
try:
csv_file.seek(0)
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.DictReader(decoded_file)
for row in csv_reader:
group_id = row.get('group_id')
category_id = row.get('category_id')
if group_id and category_id:
category = Category.objects.filter(uid=category_id).first()
if category:
if not IdentityProviderCategoryMapping.objects.filter(identity_provider=obj, name=group_id, map_to=category).exists():
mapping = IdentityProviderCategoryMapping.objects.create(identity_provider=obj, name=group_id, map_to=category) # noqa
except Exception as e:
logging.error(e)
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for form in formset.forms:
if form.cleaned_data.get('should_delete', False) and form.instance.pk:
instances.remove(form.instance)
form.instance.delete()
for instance in instances:
instance.save()
formset.save_m2m()
class CustomSocialAccountAdmin(SocialAccountAdmin):
list_display = ('user', 'uid', 'get_provider')
def get_provider(self, obj):
return obj.provider
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'provider':
field.label = 'Provider ID'
return field
get_provider.short_description = 'Provider ID'
class IdentityProviderGroupRoleInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderGroupRole
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
def clean(self):
cleaned_data = super().clean()
name = cleaned_data.get('name')
identity_provider = getattr(self.instance, 'identity_provider', None)
if name and identity_provider:
if IdentityProviderGroupRole.objects.filter(identity_provider=identity_provider, name=name).exclude(pk=self.instance.pk).exists():
self.add_error('name', 'A group role mapping with this name already exists for this Identity provider.')
class IdentityProviderGroupRoleInline(admin.TabularInline):
model = IdentityProviderGroupRole
form = IdentityProviderGroupRoleInlineForm
extra = 0
verbose_name = "Group Role Mapping"
verbose_name_plural = "Group Role Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name',) and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class IdentityProviderGlobalRoleInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderGlobalRole
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
def clean(self):
cleaned_data = super().clean()
name = cleaned_data.get('name')
identity_provider = getattr(self.instance, 'identity_provider', None)
if name and identity_provider:
if IdentityProviderGlobalRole.objects.filter(identity_provider=identity_provider, name=name).exclude(pk=self.instance.pk).exists():
self.add_error('name', 'A global role mapping with this name already exists for this Identity provider.')
class IdentityProviderGlobalRoleInline(admin.TabularInline):
model = IdentityProviderGlobalRole
form = IdentityProviderGlobalRoleInlineForm
extra = 0
verbose_name = "Global Role Mapping"
verbose_name_plural = "Global Role Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name',) and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class LoginOptionAdmin(admin.ModelAdmin):
list_display = ('title', 'url', 'ordering', 'active')
list_editable = ('ordering', 'active')
list_filter = ('active',)
search_fields = ('title', 'url')
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
admin.site.register(IdentityProviderUserLog, IdentityProviderUserLogAdmin)
admin.site.unregister(SocialToken)
# This is unregistering the default Social App and registers the custom one here,
# with mostly name setting options
IdentityProviderUserLog._meta.verbose_name = "User Logs"
IdentityProviderUserLog._meta.verbose_name_plural = "User Logs"
SocialAccount._meta.verbose_name = "User Account"
SocialAccount._meta.verbose_name_plural = "User Accounts"
admin.site.unregister(SocialApp)
admin.site.register(SocialApp, CustomSocialAppAdmin)
admin.site.register(LoginOption, LoginOptionAdmin)
admin.site.unregister(SocialAccount)
admin.site.register(SocialAccount, CustomSocialAccountAdmin)
SocialApp._meta.verbose_name = "ID Provider"
SocialApp._meta.verbose_name_plural = "ID Providers"
SocialAccount._meta.app_config.verbose_name = "Identity Providers"

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IdentityProvidersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'identity_providers'

View File

@ -0,0 +1,69 @@
import csv
from allauth.socialaccount.models import SocialApp
from django import forms
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
class ImportCSVsForm(forms.ModelForm):
groups_csv = forms.FileField(
required=False,
label="CSV file",
help_text=mark_safe("Optionally, upload a CSV file to add multiple group mappings at once. <a href='/static/templates/group_mapping.csv' class='download-template'>Download Template</a>"),
)
categories_csv = forms.FileField(
required=False,
label="CSV file",
help_text=("Optionally, upload a CSV file to add multiple category mappings at once. <a href='/static/templates/category_mapping.csv' class='download-template'>Download Template</a>"),
)
class Meta:
model = SocialApp
fields = '__all__'
def clean_groups_csv(self):
groups_csv = self.cleaned_data.get('groups_csv')
if not groups_csv:
return groups_csv
if not groups_csv.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = groups_csv.read().decode('utf-8').splitlines()
csv_reader = csv.reader(decoded_file)
headers = next(csv_reader, None)
if not headers or 'group_id' not in headers or 'name' not in headers:
raise ValidationError("CSV file must contain 'group_id' and 'name' headers. " f"Found headers: {', '.join(headers) if headers else 'none'}")
groups_csv.seek(0)
return groups_csv
except csv.Error:
raise ValidationError("Invalid CSV file. Please ensure the file is properly formatted.")
except UnicodeDecodeError:
raise ValidationError("Invalid file encoding. Please upload a CSV file with UTF-8 encoding.")
def clean_categories_csv(self):
categories_csv = self.cleaned_data.get('categories_csv')
if not categories_csv:
return categories_csv
if not categories_csv.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = categories_csv.read().decode('utf-8').splitlines()
csv_reader = csv.reader(decoded_file)
headers = next(csv_reader, None)
if not headers or 'category_id' not in headers or 'group_id' not in headers:
raise ValidationError("CSV file must contain 'group_id' and 'category_id' headers. " f"Found headers: {', '.join(headers) if headers else 'none'}")
categories_csv.seek(0)
return categories_csv
except csv.Error:
raise ValidationError("Invalid CSV file. Please ensure the file is properly formatted.")
except UnicodeDecodeError:
raise ValidationError("Invalid file encoding. Please upload a CSV file with UTF-8 encoding.")

View File

@ -0,0 +1,87 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('socialaccount', '0006_alter_socialaccount_extra_data'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='IdentityProviderUserLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('logs', models.TextField(blank=True, null=True)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_logs', to='socialaccount.socialapp')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Identity Provider User Log',
'verbose_name_plural': 'Identity Provider User Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='IdentityProviderCategoryMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider group attribute value', max_length=100, verbose_name='Group Attribute Value')),
('map_to', models.CharField(help_text='Category id', max_length=300)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_mapping', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Category Mapping',
'verbose_name_plural': 'Identity Provider Category Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
migrations.CreateModel(
name='IdentityProviderGlobalRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider role attribute value', max_length=100, verbose_name='Global Role Mapping')),
(
'map_to',
models.CharField(
choices=[
('user', 'Authenticated User'),
('advancedUser', 'Advanced User'),
('editor', 'MediaCMS Editor'),
('manager', 'MediaCMS Manager'),
('admin', 'MediaCMS Administrator'),
],
help_text='MediaCMS Global Role',
max_length=20,
),
),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_roles', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Global Role Mapping',
'verbose_name_plural': 'Identity Provider Global Role Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
migrations.CreateModel(
name='IdentityProviderGroupRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider role attribute value', max_length=100, verbose_name='Group Role Mapping')),
('map_to', models.CharField(choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='MediaCMS Group Role', max_length=20)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_roles', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Group Role Mapping',
'verbose_name_plural': 'Identity Provider Group Role Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-20 18:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity_providers', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='LoginOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Display name for this login option (e.g. Login through DEIC)', max_length=100)),
('url', models.CharField(help_text='URL or path for this login option', max_length=255)),
('ordering', models.PositiveIntegerField(default=0, help_text='Display order (smaller numbers appear first)')),
('active', models.BooleanField(default=True, help_text='Whether this login option is currently active')),
],
options={
'verbose_name': 'Login Option',
'verbose_name_plural': 'Login Options',
'ordering': ['ordering'],
},
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.1.6 on 2025-03-25 15:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('identity_providers', '0002_loginoption'),
]
operations = [
migrations.AlterUniqueTogether(
name='identityprovidercategorymapping',
unique_together=set(),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 15:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0005_alter_category_uid'),
('identity_providers', '0003_alter_identityprovidercategorymapping_unique_together'),
]
operations = [
migrations.AlterField(
model_name='identityprovidercategorymapping',
name='map_to',
field=models.ForeignKey(help_text='Category id', on_delete=django.db.models.deletion.CASCADE, to='files.category'),
),
]

View File

@ -0,0 +1,125 @@
from allauth.socialaccount.models import SocialApp
from django.core.exceptions import ValidationError
from django.db import models
class IdentityProviderUserLog(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='saml_logs')
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name='saml_logs')
created_at = models.DateTimeField(auto_now_add=True)
logs = models.TextField(blank=True, null=True)
class Meta:
verbose_name = 'Identity Provider User Log'
verbose_name_plural = 'Identity Provider User Logs'
ordering = ['-created_at']
def __str__(self):
return f'SAML Log - {self.user.username} - {self.created_at}'
class IdentityProviderGroupRole(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='group_roles')
name = models.CharField(verbose_name='Group Role Mapping', max_length=100, help_text='Identity Provider role attribute value')
map_to = models.CharField(max_length=20, choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='MediaCMS Group Role')
class Meta:
verbose_name = 'Identity Provider Group Role Mapping'
verbose_name_plural = 'Identity Provider Group Role Mappings'
unique_together = ('identity_provider', 'name')
def __str__(self):
return f'Identity Provider Group Role Mapping {self.name}'
def save(self, *args, **kwargs):
if not self.identity_provider:
raise ValidationError({'identity_provider': 'Identity Provider is required.'})
if IdentityProviderGroupRole.objects.filter(identity_provider=self.identity_provider, name=self.name).exclude(pk=self.pk).exists():
raise ValidationError({'name': 'A group role mapping for this Identity Provider with this name already exists.'})
super().save(*args, **kwargs)
class IdentityProviderGlobalRole(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='global_roles')
name = models.CharField(verbose_name='Global Role Mapping', max_length=100, help_text='Identity Provider role attribute value')
map_to = models.CharField(
max_length=20,
choices=[('user', 'Authenticated User'), ('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')],
help_text='MediaCMS Global Role',
)
class Meta:
verbose_name = 'Identity Provider Global Role Mapping'
verbose_name_plural = 'Identity Provider Global Role Mappings'
unique_together = ('identity_provider', 'name')
def __str__(self):
return f'Identity Provider Global Role Mapping {self.name}'
def save(self, *args, **kwargs):
if not self.identity_provider:
raise ValidationError({'identity_provider': 'Identity Provider is required.'})
if IdentityProviderGlobalRole.objects.filter(identity_provider=self.identity_provider, name=self.name).exclude(pk=self.pk).exists():
raise ValidationError({'name': 'A global role mapping for this Identity Provider with this name already exists.'})
super().save(*args, **kwargs)
class IdentityProviderCategoryMapping(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='category_mapping')
name = models.CharField(verbose_name='Group Attribute Value', max_length=100, help_text='Identity Provider group attribute value')
map_to = models.ForeignKey('files.Category', on_delete=models.CASCADE, help_text='Category id')
class Meta:
verbose_name = 'Identity Provider Category Mapping'
verbose_name_plural = 'Identity Provider Category Mappings'
def clean(self):
if not self._state.adding and self.pk:
original = IdentityProviderCategoryMapping.objects.get(pk=self.pk)
if original.name != self.name:
raise ValidationError("Cannot change the name once it is set. First delete this entry and then create a new one instead.")
super().clean()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from rbac.models import RBACGroup
group = RBACGroup.objects.filter(identity_provider=self.identity_provider, uid=self.name).first()
if group:
group.categories.add(self.map_to)
return True
def __str__(self):
return f'Identity Provider Category Mapping {self.name}'
def delete(self, *args, **kwargs):
from rbac.models import RBACGroup
group = RBACGroup.objects.filter(identity_provider=self.identity_provider, uid=self.name).first()
if group:
group.categories.remove(self.map_to)
super().delete(*args, **kwargs)
class LoginOption(models.Model):
title = models.CharField(max_length=100, help_text="Display name for this login option (e.g. Login through DEIC)")
url = models.CharField(max_length=255, help_text="URL or path for this login option")
ordering = models.PositiveIntegerField(default=0, help_text="Display order (smaller numbers appear first)")
active = models.BooleanField(default=True, help_text="Whether this login option is currently active")
class Meta:
ordering = ['ordering']
verbose_name = "Login Option"
verbose_name_plural = "Login Options"
def __str__(self):
return self.title

View File

View File

0
rbac/__init__.py Normal file
View File

212
rbac/admin.py Normal file
View File

@ -0,0 +1,212 @@
from django import forms
from django.conf import settings
from django.contrib import admin
from django.db import transaction
from django.utils.html import format_html
from files.models import Category
from users.models import User
from .models import RBACGroup, RBACMembership, RBACRole
class RoleFilter(admin.SimpleListFilter):
title = 'Role'
parameter_name = 'role'
def lookups(self, request, model_admin):
return RBACRole.choices
def queryset(self, request, queryset):
if self.value():
return queryset.filter(memberships__role=self.value()).distinct()
return queryset
class RBACGroupAdminForm(forms.ModelForm):
categories = forms.ModelMultipleChoiceField(
queryset=Category.objects.filter(is_rbac_category=True),
required=False,
widget=admin.widgets.FilteredSelectMultiple('Categories', False),
help_text='Select categories this RBAC group has access to',
)
members_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Members', False), help_text='Users with Member role', label=''
)
contributors_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Contributors', False), help_text='Users with Contributor role', label=''
)
managers_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Managers', False), help_text='Users with Manager role', label=''
)
class Meta:
model = RBACGroup
fields = ('name',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['categories'].initial = self.instance.categories.all()
self.fields['members_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MEMBER)
self.fields['contributors_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.CONTRIBUTOR)
self.fields['managers_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MANAGER)
def save(self, commit=True):
group = super().save(commit=True)
if commit:
self.save_m2m()
if 'categories' in self.cleaned_data:
self.instance.categories.set(self.cleaned_data['categories'])
return group
@transaction.atomic
def save_m2m(self):
if self.instance.pk:
member_users = self.cleaned_data['members_field']
contributor_users = self.cleaned_data['contributors_field']
manager_users = self.cleaned_data['managers_field']
self._update_role_memberships(RBACRole.MEMBER, member_users)
self._update_role_memberships(RBACRole.CONTRIBUTOR, contributor_users)
self._update_role_memberships(RBACRole.MANAGER, manager_users)
def _update_role_memberships(self, role, new_users):
new_user_ids = User.objects.filter(pk__in=new_users).values_list('pk', flat=True)
existing_users = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=role)
existing_user_ids = existing_users.values_list('pk', flat=True)
users_to_add = User.objects.filter(pk__in=new_user_ids).exclude(pk__in=existing_user_ids)
users_to_remove = existing_users.exclude(pk__in=new_user_ids)
for user in users_to_add:
RBACMembership.objects.get_or_create(user=user, rbac_group=self.instance, role=role)
RBACMembership.objects.filter(user__in=users_to_remove, rbac_group=self.instance, role=role).delete()
class RBACGroupAdmin(admin.ModelAdmin):
list_display = ('name', 'get_member_count', 'get_contributor_count', 'get_manager_count', 'categories_list')
form = RBACGroupAdminForm
list_filter = (RoleFilter,)
search_fields = ['name', 'uid', 'description', 'identity_provider__name']
filter_horizontal = ['categories']
change_form_template = 'admin/rbac/rbacgroup/change_form.html'
def get_list_filter(self, request):
list_filter = list(self.list_filter)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_filter.insert(-1, "identity_provider")
return list_filter
def get_list_display(self, request):
list_display = list(self.list_display)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_display.insert(-1, "identity_provider")
return list_display
def get_member_count(self, obj):
return obj.memberships.filter(role=RBACRole.MEMBER).count()
get_member_count.short_description = 'Members'
def get_contributor_count(self, obj):
return obj.memberships.filter(role=RBACRole.CONTRIBUTOR).count()
get_contributor_count.short_description = 'Contributors'
def get_manager_count(self, obj):
return obj.memberships.filter(role=RBACRole.MANAGER).count()
get_manager_count.short_description = 'Managers'
fieldsets = (
(
None,
{
'fields': ('uid', 'name', 'description', 'created_at', 'updated_at'),
},
),
)
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
fieldsets = (
(
None,
{
'fields': ('identity_provider', 'uid', 'name', 'description', 'created_at', 'updated_at'),
},
),
)
if obj:
fieldsets += (
('Members', {'fields': ['members_field'], 'description': 'Select users for members. The same user cannot be contributor or manager'}),
('Contributors', {'fields': ['contributors_field'], 'description': 'Select users for contributors. The same user cannot be member or manager'}),
('Managers', {'fields': ['managers_field'], 'description': 'Select users for managers. The same user cannot be member or contributor'}),
('Access To Categories', {'fields': ['categories'], 'classes': ['collapse', 'open'], 'description': 'Select which categories this RBAC group has access to'}),
)
return fieldsets
readonly_fields = ['created_at', 'updated_at']
def member_count(self, obj):
count = obj.memberships.count()
return format_html('<a href="?rbac_group__id__exact={}">{} members</a>', obj.id, count)
member_count.short_description = 'Members'
def categories_list(self, obj):
return ", ".join([c.title for c in obj.categories.all()])
categories_list.short_description = 'Categories'
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'social_app':
field.label = 'ID Provider'
return field
class RBACMembershipAdmin(admin.ModelAdmin):
list_display = ['user', 'rbac_group', 'role', 'joined_at', 'updated_at']
list_filter = ['role', 'rbac_group', 'joined_at', 'updated_at']
search_fields = ['user__username', 'user__email', 'rbac_group__name', 'rbac_group__uid']
raw_id_fields = ['user']
autocomplete_fields = ['user']
readonly_fields = ['joined_at', 'updated_at']
fieldsets = [(None, {'fields': ['user', 'rbac_group', 'role']}), ('Timestamps', {'fields': ['joined_at', 'updated_at'], 'classes': ['collapse']})]
if getattr(settings, 'USE_RBAC', False):
for field in RBACGroup._meta.fields:
if field.name == 'social_app':
field.verbose_name = "ID Provider"
RBACGroup._meta.verbose_name_plural = "Groups"
RBACGroup._meta.verbose_name = "Group"
RBACMembership._meta.verbose_name_plural = "Role Based Access Control Membership"
RBACGroup._meta.app_config.verbose_name = "Role Based Access Control"
admin.site.register(RBACGroup, RBACGroupAdmin)
admin.site.register(RBACMembership, RBACMembershipAdmin)

6
rbac/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class RbacConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rbac'

View File

@ -0,0 +1,63 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
('socialaccount', '0006_alter_socialaccount_extra_data'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RBACGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uid', models.CharField(help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255)),
('name', models.CharField(max_length=100, help_text='MediaCMS Group name')),
('description', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('categories', models.ManyToManyField(blank=True, help_text='Categories this RBAC group has access to', related_name='rbac_groups', to='files.category')),
(
'identity_provider',
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rbac_groups', to='socialaccount.socialapp', verbose_name='IDP Config Name'),
),
],
options={
'verbose_name': 'RBAC Group',
'verbose_name_plural': 'RBAC Groups',
},
),
migrations.CreateModel(
name='RBACMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], default='member', max_length=20)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('rbac_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='rbac.rbacgroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rbac_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'RBAC Membership',
'verbose_name_plural': 'RBAC Memberships',
'unique_together': {('user', 'rbac_group', 'role')},
},
),
migrations.AddField(
model_name='rbacgroup',
name='members',
field=models.ManyToManyField(related_name='rbac_groups', through='rbac.RBACMembership', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='rbacgroup',
unique_together={('name', 'identity_provider'), ('uid', 'identity_provider')},
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 14:18
from django.db import migrations, models
import rbac.models
class Migration(migrations.Migration):
dependencies = [
('rbac', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='rbacgroup',
name='uid',
field=models.CharField(default=rbac.models.generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255),
),
]

View File

96
rbac/models.py Normal file
View File

@ -0,0 +1,96 @@
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.db import models
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.utils.crypto import get_random_string
def generate_uid():
return get_random_string(length=10)
class RBACGroup(models.Model):
uid = models.CharField(max_length=255, default=generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)')
name = models.CharField(max_length=100, help_text='MediaCMS Group name')
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# access to members through the membership model
members = models.ManyToManyField("users.User", through='RBACMembership', through_fields=('rbac_group', 'user'), related_name='rbac_groups')
categories = models.ManyToManyField('files.Category', related_name='rbac_groups', blank=True, help_text='Categories this RBAC group has access to')
identity_provider = models.ForeignKey(SocialApp, on_delete=models.SET_NULL, null=True, blank=True, related_name='rbac_groups', verbose_name='IDP Config Name')
def __str__(self):
name = f"{self.name}"
if self.identity_provider:
name = f"{name} for {self.identity_provider}"
return name
class Meta:
verbose_name = 'RBAC Group'
verbose_name_plural = 'RBAC Groups'
unique_together = [['uid', 'identity_provider'], ['name', 'identity_provider']]
class RBACRole(models.TextChoices):
MEMBER = 'member', 'Member'
CONTRIBUTOR = 'contributor', 'Contributor'
MANAGER = 'manager', 'Manager'
class RBACMembership(models.Model):
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name='rbac_memberships')
rbac_group = models.ForeignKey(RBACGroup, on_delete=models.CASCADE, related_name='memberships')
role = models.CharField(max_length=20, choices=RBACRole.choices, default=RBACRole.MEMBER)
joined_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'rbac_group', 'role']
verbose_name = 'RBAC Membership'
verbose_name_plural = 'RBAC Memberships'
def clean(self):
super().clean()
return True
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return f'{self.user.username} - {self.rbac_group.name} ({self.role})'
@receiver(m2m_changed, sender=RBACGroup.categories.through)
def handle_rbac_group_categories_change(sender, instance, action, pk_set, **kwargs):
"""
Signal handler for when categories are added to or removed from an RBACGroup.
"""
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
return
from files.models import Category
from identity_providers.models import IdentityProviderCategoryMapping
if action == 'post_add':
if not instance.identity_provider:
return
# the following apply only if identity_provider is there
for category_id in pk_set:
category = Category.objects.get(pk=category_id)
mapping_exists = IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).exists()
if not mapping_exists:
IdentityProviderCategoryMapping.objects.create(identity_provider=instance.identity_provider, name=instance.uid, map_to=category)
elif action == 'post_remove':
for category_id in pk_set:
category = Category.objects.get(pk=category_id)
IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).delete()

0
rbac/tests.py Normal file
View File

0
rbac/views.py Normal file
View File

View File

@ -1,5 +1,7 @@
Django==5.1.6
djangorestframework==3.15.2
lxml==5.0.0 # dont use later version, as theres a strange error "lxml & xmlsec libxml2 library version mismatch"
python3-saml==1.16.0
django-allauth==65.4.1
psycopg==3.2.4
uwsgi==2.0.28
@ -19,4 +21,7 @@ m3u8==6.0.0
django-debug-toolbar==5.0.1
django-login-required-middleware==0.9.0
pre-commit==4.1.0
django-jazzmin==3.0.1
pysubs2==1.8.0
sentry-sdk[django]==2.23.1

0
saml_auth/__init__.py Normal file
View File

153
saml_auth/adapter.py Normal file
View File

@ -0,0 +1,153 @@
import base64
import logging
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.signals import social_account_updated
from django.core.files.base import ContentFile
from django.dispatch import receiver
from identity_providers.models import IdentityProviderUserLog
from rbac.models import RBACGroup, RBACMembership
class SAMLAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, socialaccount):
return True
def pre_social_login(self, request, sociallogin):
# data = sociallogin.data
return super().pre_social_login(request, sociallogin)
def populate_user(self, request, sociallogin, data):
user = sociallogin.user
user.username = sociallogin.account.uid
for item in ["name", "first_name", "last_name"]:
if data.get(item):
setattr(user, item, data[item])
sociallogin.data = data
# User is not retrieved through DB. Id is None.
return user
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)
# Runs after new user is created
perform_user_actions(user, sociallogin.account)
return user
@receiver(social_account_updated)
def social_account_updated(sender, request, sociallogin, **kwargs):
# Runs after existing user is updated
user = sociallogin.user
# data is there due to populate_user
common_fields = sociallogin.data
perform_user_actions(user, sociallogin.account, common_fields)
def perform_user_actions(user, social_account, common_fields=None):
# common_fields is data already mapped to the attributes we want
if common_fields:
# check the following fields, if they are updated from the IDP side, update
# the user object too
fields_to_update = []
for item in ["name", "first_name", "last_name", "email"]:
if common_fields.get(item) and common_fields[item] != getattr(user, item):
setattr(user, item, common_fields[item])
fields_to_update.append(item)
if fields_to_update:
user.save(update_fields=fields_to_update)
# extra_data is the plain response from SAML provider
extra_data = social_account.extra_data
# there's no FK from Social Account to Social App
social_app = SocialApp.objects.filter(provider_id=social_account.provider).first()
saml_configuration = None
if social_app:
saml_configuration = social_app.saml_configurations.first()
add_user_logo(user, extra_data)
handle_role_mapping(user, extra_data, social_app, saml_configuration)
if saml_configuration and saml_configuration.save_saml_response_logs:
handle_saml_logs_save(user, extra_data, social_app)
return user
def add_user_logo(user, extra_data):
try:
if extra_data.get("jpegPhoto") and user.logo.name in ["userlogos/user.jpg", "", None]:
base64_string = extra_data.get("jpegPhoto")[0]
image_data = base64.b64decode(base64_string)
image_content = ContentFile(image_data)
user.logo.save('user.jpg', image_content, save=True)
except Exception as e:
logging.error(e)
return True
def handle_role_mapping(user, extra_data, social_app, saml_configuration):
if not saml_configuration:
return False
rbac_groups = []
role = "member"
# get groups key from configuration / attributes mapping
groups_key = saml_configuration.groups
groups = extra_data.get(groups_key, [])
# groups is a list of group_ids here
if groups:
rbac_groups = RBACGroup.objects.filter(identity_provider=social_app, uid__in=groups)
try:
# try to get the role, always use member as fallback
role_key = saml_configuration.role
role = extra_data.get(role_key, "student")
if role and isinstance(role, list):
role = role[0]
# populate global role
global_role = social_app.global_roles.filter(name=role).first()
if global_role:
user.set_role_from_mapping(global_role.map_to)
group_role = social_app.group_roles.filter(name=role).first()
if group_role:
if group_role.map_to in ['member', 'contributor', 'manager']:
role = group_role.map_to
except Exception as e:
logging.error(e)
role = role if role in ['member', 'contributor', 'manager'] else 'member'
for rbac_group in rbac_groups:
membership = RBACMembership.objects.filter(user=user, rbac_group=rbac_group).first()
if membership and role != membership.role:
membership.role = role
membership.save(update_fields=["role"])
if not membership:
try:
# use role from early above
membership = RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=role)
except Exception as e:
logging.error(e)
# if remove_from_groups setting is True and user is part of groups for this
# social app that are not included anymore on the response, then remove user from group
if saml_configuration.remove_from_groups:
for group in user.rbac_groups.filter(identity_provider=social_app):
if group not in rbac_groups:
group.members.remove(user)
return True
def handle_saml_logs_save(user, extra_data, social_app):
# do not save jpegPhoto, if it exists
extra_data.pop("jpegPhoto", None)
log = IdentityProviderUserLog.objects.create(user=user, identity_provider=social_app, logs=extra_data) # noqa
return True

123
saml_auth/admin.py Normal file
View File

@ -0,0 +1,123 @@
import csv
import logging
from django import forms
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from .models import SAMLConfiguration
class SAMLConfigurationForm(forms.ModelForm):
import_csv = forms.FileField(required=False, label="CSV file", help_text="Make sure headers are group_id, name")
class Meta:
model = SAMLConfiguration
fields = '__all__'
def clean_import_csv(self):
csv_file = self.cleaned_data.get('import_csv')
if not csv_file:
return csv_file
if not csv_file.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.reader(decoded_file)
headers = next(csv_reader, None)
if not headers or 'group_id' not in headers or 'name' not in headers:
raise ValidationError("CSV file must contain 'group_id' and 'name' headers. " f"Found headers: {', '.join(headers) if headers else 'none'}")
csv_file.seek(0)
return csv_file
except csv.Error:
raise ValidationError("Invalid CSV file. Please ensure the file is properly formatted.")
except UnicodeDecodeError:
raise ValidationError("Invalid file encoding. Please upload a CSV file with UTF-8 encoding.")
class SAMLConfigurationAdmin(admin.ModelAdmin):
form = SAMLConfigurationForm
list_display = ['social_app', 'idp_id', 'remove_from_groups', 'save_saml_response_logs', 'view_metadata_url']
list_filter = ['social_app', 'remove_from_groups', 'save_saml_response_logs']
search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url']
fieldsets = [
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert']}),
('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}),
('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}),
('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}),
(
'Email Settings',
{
'fields': [
'verified_email',
'email_authentication',
]
},
),
]
def view_metadata_url(self, obj):
"""Display metadata URL as a clickable link"""
return format_html('<a href="{}" target="_blank">View Metadata</a>', obj.sp_metadata_url)
view_metadata_url.short_description = 'Metadata'
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'social_app':
field.label = 'IDP Config Name'
return field
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
fieldsets = list(fieldsets)
fieldsets.append(('BULK GROUP MAPPINGS', {'fields': ('import_csv',), 'description': 'Optionally upload a CSV file with group_id and name as headers to add multiple group mappings at once.'}))
return fieldsets
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
csv_file = form.cleaned_data.get('import_csv')
if csv_file:
from rbac.models import RBACGroup
try:
csv_file.seek(0)
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.DictReader(decoded_file)
for row in csv_reader:
group_id = row.get('group_id')
name = row.get('name')
if group_id and name:
if not RBACGroup.objects.filter(uid=group_id, social_app=obj.social_app).exists():
try:
rbac_group = RBACGroup.objects.create(uid=group_id, name=name, social_app=obj.social_app) # noqa
except Exception as e:
logging.error(e)
except Exception as e:
logging.error(e)
if getattr(settings, 'USE_SAML', False):
for field in SAMLConfiguration._meta.fields:
if field.name == 'social_app':
field.verbose_name = "ID Provider"
admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin)
SAMLConfiguration._meta.app_config.verbose_name = "SAML settings and logs"
SAMLConfiguration._meta.verbose_name_plural = "SAML Configuration"

6
saml_auth/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SamlAuthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'saml_auth'

View File

View File

@ -0,0 +1,61 @@
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.http import HttpResponseRedirect
from saml_auth.custom.utils import build_auth
class SAMLAccount(ProviderAccount):
pass
class CustomSAMLProvider(SAMLProvider):
def _extract(self, data):
custom_configuration = self.app.saml_configurations.first()
if custom_configuration:
provider_config = custom_configuration.saml_provider_settings
else:
provider_config = self.app.settings
raw_attributes = data.get_attributes()
attributes = {}
attribute_mapping = provider_config.get("attribute_mapping", self.default_attribute_mapping)
# map configured provider attributes
for key, provider_keys in attribute_mapping.items():
if isinstance(provider_keys, str):
provider_keys = [provider_keys]
for provider_key in provider_keys:
attribute_list = raw_attributes.get(provider_key, None)
# if more than one keys, get them all comma separated
if attribute_list is not None and len(attribute_list) > 1:
attributes[key] = ",".join(attribute_list)
break
elif attribute_list is not None and len(attribute_list) > 0:
attributes[key] = attribute_list[0]
break
attributes["email_verified"] = False
email_verified = provider_config.get("email_verified", False)
if email_verified:
if isinstance(email_verified, str):
email_verified = email_verified.lower() in ["true", "1", "t", "y", "yes"]
attributes["email_verified"] = email_verified
# return username as the uid value
if "uid" in attributes:
attributes["username"] = attributes["uid"]
# If we did not find an email, check if the NameID contains the email.
if not attributes.get("email") and (
data.get_nameid_format() == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
# Alternatively, if `use_id_for_email` is true, then we always interpret the nameID as email
or provider_config.get("use_nameid_for_email", False) # noqa
):
attributes["email"] = data.get_nameid()
return attributes
def redirect(self, request, process, next_url=None, data=None, **kwargs):
auth = build_auth(request, self)
# If we pass `return_to=None` `auth.login` will use the URL of the
# current view.
redirect = auth.login(return_to="")
self.stash_redirect_state(request, process, next_url, data, state_id=auth.get_last_request_id(), **kwargs)
return HttpResponseRedirect(redirect)

38
saml_auth/custom/urls.py Normal file
View File

@ -0,0 +1,38 @@
from django.urls import include, path, re_path
from . import views
urlpatterns = [
re_path(
r"^saml/(?P<organization_slug>[^/]+)/",
include(
[
path(
"acs/",
views.acs,
name="saml_acs",
),
path(
"acs/finish/",
views.finish_acs,
name="saml_finish_acs",
),
path(
"sls/",
views.sls,
name="saml_sls",
),
path(
"metadata/",
views.metadata,
name="saml_metadata",
),
path(
"login/",
views.login,
name="saml_login",
),
]
),
)
]

173
saml_auth/custom/utils.py Normal file
View File

@ -0,0 +1,173 @@
from urllib.parse import urlparse
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from django.urls import reverse
from django.utils.http import urlencode
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.constants import OneLogin_Saml2_Constants
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
def get_app_or_404(request, organization_slug):
adapter = get_adapter()
try:
return adapter.get_app(request, provider=SAMLProvider.id, client_id=organization_slug)
except SocialApp.DoesNotExist:
raise Http404(f"no SocialApp found with client_id={organization_slug}")
def prepare_django_request(request):
result = {
"https": "on" if request.is_secure() else "off",
"http_host": request.META["HTTP_HOST"],
"script_name": request.META["PATH_INFO"],
"get_data": request.GET.copy(),
# 'lowercase_urlencoding': True,
"post_data": request.POST.copy(),
}
return result
def build_sp_config(request, provider_config, org):
acs_url = request.build_absolute_uri(reverse("saml_acs", args=[org]))
sls_url = request.build_absolute_uri(reverse("saml_sls", args=[org]))
metadata_url = request.build_absolute_uri(reverse("saml_metadata", args=[org]))
# SP entity ID generated with the following precedence:
# 1. Explicitly configured SP via the SocialApp.settings
# 2. Fallback to the SAML metadata urlpattern
_sp_config = provider_config.get("sp", {})
sp_entity_id = _sp_config.get("entity_id")
sp_config = {
"entityId": sp_entity_id or metadata_url,
"assertionConsumerService": {
"url": acs_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_POST,
},
"singleLogoutService": {
"url": sls_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
},
}
avd = provider_config.get("advanced", {})
if avd.get("x509cert") is not None:
sp_config["x509cert"] = avd["x509cert"]
if avd.get("x509cert_new"):
sp_config["x509certNew"] = avd["x509cert_new"]
if avd.get("private_key") is not None:
sp_config["privateKey"] = avd["private_key"]
if avd.get("name_id_format") is not None:
sp_config["NameIDFormat"] = avd["name_id_format"]
return sp_config
def fetch_metadata_url_config(idp_config):
metadata_url = idp_config["metadata_url"]
entity_id = idp_config["entity_id"]
cache_key = f"saml.metadata.{metadata_url}.{entity_id}"
saml_config = cache.get(cache_key)
if saml_config is None:
saml_config = OneLogin_Saml2_IdPMetadataParser.parse_remote(
metadata_url,
entity_id=entity_id,
timeout=idp_config.get("metadata_request_timeout", 10),
)
cache.set(
cache_key,
saml_config,
idp_config.get("metadata_cache_timeout", 60 * 60 * 4),
)
return saml_config
def build_saml_config(request, provider_config, org):
avd = provider_config.get("advanced", {})
security_config = {
"authnRequestsSigned": avd.get("authn_request_signed", False),
"digestAlgorithm": avd.get("digest_algorithm", OneLogin_Saml2_Constants.SHA256),
"logoutRequestSigned": avd.get("logout_request_signed", False),
"logoutResponseSigned": avd.get("logout_response_signed", False),
"requestedAuthnContext": False,
"signatureAlgorithm": avd.get("signature_algorithm", OneLogin_Saml2_Constants.RSA_SHA256),
"signMetadata": avd.get("metadata_signed", False),
"wantAssertionsEncrypted": avd.get("want_assertion_encrypted", False),
"wantAssertionsSigned": avd.get("want_assertion_signed", False),
"wantMessagesSigned": avd.get("want_message_signed", False),
"nameIdEncrypted": avd.get("name_id_encrypted", False),
"wantNameIdEncrypted": avd.get("want_name_id_encrypted", False),
"allowSingleLabelDomains": avd.get("allow_single_label_domains", False),
"rejectDeprecatedAlgorithm": avd.get("reject_deprecated_algorithm", True),
"wantNameId": avd.get("want_name_id", False),
"wantAttributeStatement": avd.get("want_attribute_statement", True),
"allowRepeatAttributeName": avd.get("allow_repeat_attribute_name", True),
}
saml_config = {
"strict": avd.get("strict", True),
"security": security_config,
}
contact_person = provider_config.get("contact_person")
if contact_person:
saml_config["contactPerson"] = contact_person
organization = provider_config.get("organization")
if organization:
saml_config["organization"] = organization
idp = provider_config.get("idp")
if idp is None:
raise ImproperlyConfigured("`idp` missing")
metadata_url = idp.get("metadata_url")
if metadata_url:
meta_config = fetch_metadata_url_config(idp)
saml_config["idp"] = meta_config["idp"]
else:
saml_config["idp"] = {
"entityId": idp["entity_id"],
"x509cert": idp["x509cert"],
"singleSignOnService": {"url": idp["sso_url"]},
}
slo_url = idp.get("slo_url")
if slo_url:
saml_config["idp"]["singleLogoutService"] = {"url": slo_url}
saml_config["sp"] = build_sp_config(request, provider_config, org)
return saml_config
def encode_relay_state(state):
params = {"state": state}
return urlencode(params)
def decode_relay_state(relay_state):
"""According to the spec, RelayState need not be a URL, yet,
``onelogin.saml2` exposes it as ``return_to -- The target URL the user
should be redirected to after login``. Also, for an IdP initiated login
sometimes a URL is used.
"""
next_url = None
if relay_state:
parts = urlparse(relay_state)
if parts.scheme or parts.netloc or (parts.path and parts.path.startswith("/")):
next_url = relay_state
return next_url
def build_auth(request, provider):
req = prepare_django_request(request)
custom_configuration = provider.app.saml_configurations.first()
if custom_configuration:
custom_settings = custom_configuration.saml_provider_settings
config = build_saml_config(request, custom_settings, provider.app.client_id)
else:
config = build_saml_config(request, provider.app.settings, provider.app.client_id)
auth = OneLogin_Saml2_Auth(req, config)
return auth

180
saml_auth/custom/views.py Normal file
View File

@ -0,0 +1,180 @@
import binascii
import logging
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.decorators import login_not_required
from allauth.core.internal import httpkit
from allauth.socialaccount.helpers import (
complete_social_login,
render_authentication_error,
)
from allauth.socialaccount.providers.base.constants import AuthError, AuthProcess
from allauth.socialaccount.providers.base.views import BaseLoginView
from allauth.socialaccount.sessions import LoginSession
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from onelogin.saml2.auth import OneLogin_Saml2_Settings
from onelogin.saml2.errors import OneLogin_Saml2_Error
from .utils import build_auth, build_saml_config, decode_relay_state, get_app_or_404
logger = logging.getLogger(__name__)
class SAMLViewMixin:
def get_app(self, organization_slug):
app = get_app_or_404(self.request, organization_slug)
return app
def get_provider(self, organization_slug):
app = self.get_app(organization_slug)
return app.get_provider(self.request)
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class ACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
url = reverse(
"saml_finish_acs",
kwargs={"organization_slug": organization_slug},
)
response = HttpResponseRedirect(url)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_session.store.update({"request": httpkit.serialize_request(request)})
acs_session.save(response)
return response
acs = ACSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class FinishACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_request = None
acs_request_data = acs_session.store.get("request")
if acs_request_data:
acs_request = httpkit.deserialize_request(acs_request_data, HttpRequest())
acs_session.delete()
if not acs_request:
logger.error("Unable to finish login, SAML ACS session missing")
return render_authentication_error(request, provider)
auth = build_auth(acs_request, provider)
error_reason = None
errors = []
try:
# We're doing the check for a valid `InResponeTo` ourselves later on
# (*) by checking if there is a matching state stashed.
auth.process_response(request_id=None)
except binascii.Error:
errors = ["invalid_response"]
error_reason = "Invalid response"
except OneLogin_Saml2_Error as e:
errors = ["error"]
error_reason = str(e)
if not errors:
errors = auth.get_errors()
if errors:
# e.g. ['invalid_response']
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML ACS response: %s: %s" % (", ".join(errors), error_reason))
return render_authentication_error(
request,
provider,
extra_context={
"saml_errors": errors,
"saml_last_error_reason": error_reason,
},
)
if not auth.is_authenticated():
return render_authentication_error(request, provider, error=AuthError.CANCELLED)
login = provider.sociallogin_from_response(request, auth)
# (*) If we (the SP) initiated the login, there should be a matching
# state.
state_id = auth.get_last_response_in_response_to()
if state_id:
login.state = provider.unstash_redirect_state(request, state_id)
else:
# IdP initiated SSO
reject = provider.app.settings.get("advanced", {}).get("reject_idp_initiated_sso", True)
if reject:
logger.error("IdP initiated SSO rejected")
return render_authentication_error(request, provider)
next_url = decode_relay_state(acs_request.POST.get("RelayState"))
login.state["process"] = AuthProcess.LOGIN
if next_url:
login.state["next"] = next_url
return complete_social_login(request, login)
finish_acs = FinishACSView.as_view()
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class SLSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
auth = build_auth(self.request, provider)
should_logout = request.user.is_authenticated
account_adapter = get_account_adapter(request)
def force_logout():
account_adapter.logout(request)
redirect_to = None
error_reason = None
try:
redirect_to = auth.process_slo(delete_session_cb=force_logout, keep_local_session=not should_logout)
except OneLogin_Saml2_Error as e:
error_reason = str(e)
errors = auth.get_errors()
if errors:
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML SLS response: %s: %s" % (", ".join(errors), error_reason))
resp = HttpResponse(error_reason, content_type="text/plain")
resp.status_code = 400
return resp
if not redirect_to:
redirect_to = account_adapter.get_logout_redirect_url(request)
return HttpResponseRedirect(redirect_to)
sls = SLSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class MetadataView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
config = build_saml_config(self.request, provider.app.settings, organization_slug)
saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True)
metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata)
if len(errors) > 0:
resp = JsonResponse({"errors": errors})
resp.status_code = 500
return resp
return HttpResponse(content=metadata, content_type="text/xml")
metadata = MetadataView.as_view()
@method_decorator(login_not_required, name="dispatch")
class LoginView(SAMLViewMixin, BaseLoginView):
def get_provider(self):
app = self.get_app(self.kwargs["organization_slug"])
return app.get_provider(self.request)
login = LoginView.as_view()

View File

@ -0,0 +1,44 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('socialaccount', '0006_alter_socialaccount_extra_data'),
]
operations = [
migrations.CreateModel(
name='SAMLConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sso_url', models.URLField(help_text='Sign-in URL')),
('slo_url', models.URLField(help_text='Sign-out URL')),
('sp_metadata_url', models.URLField(help_text='https://host/saml/metadata')),
('idp_id', models.URLField(help_text='Identity Provider ID')),
('idp_cert', models.TextField(help_text='x509cert')),
('uid', models.CharField(help_text='eg eduPersonPrincipalName', max_length=100)),
('name', models.CharField(blank=True, help_text='eg displayName', max_length=100, null=True)),
('email', models.CharField(blank=True, help_text='eg mail', max_length=100, null=True)),
('groups', models.CharField(blank=True, help_text='eg isMemberOf', max_length=100, null=True)),
('first_name', models.CharField(blank=True, help_text='eg gn', max_length=100, null=True)),
('last_name', models.CharField(blank=True, help_text='eg sn', max_length=100, null=True)),
('user_logo', models.CharField(blank=True, help_text='eg jpegPhoto', max_length=100, null=True)),
('role', models.CharField(blank=True, help_text='eduPersonPrimaryAffiliation', max_length=100, null=True)),
('verified_email', models.BooleanField(default=False, help_text='Mark email as verified')),
('email_authentication', models.BooleanField(default=False, help_text='Use email authentication too')),
('remove_from_groups', models.BooleanField(default=False, help_text='Automatically remove from groups')),
('save_saml_response_logs', models.BooleanField(default=True)),
('social_app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_configurations', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'SAML Configuration',
'verbose_name_plural': 'SAML Configurations',
'unique_together': {('social_app', 'idp_id')},
},
),
]

View File

72
saml_auth/models.py Normal file
View File

@ -0,0 +1,72 @@
from allauth.socialaccount.models import SocialApp
from django.core.exceptions import ValidationError
from django.db import models
class SAMLConfiguration(models.Model):
social_app = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='saml_configurations')
# URLs
sso_url = models.URLField(help_text='Sign-in URL')
slo_url = models.URLField(help_text='Sign-out URL')
sp_metadata_url = models.URLField(help_text='https://host/saml/metadata')
idp_id = models.URLField(help_text='Identity Provider ID')
# Certificates
idp_cert = models.TextField(help_text='x509cert')
# Attribute Mapping Fields
uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName')
name = models.CharField(max_length=100, blank=True, null=True, help_text='eg displayName')
email = models.CharField(max_length=100, blank=True, null=True, help_text='eg mail')
groups = models.CharField(max_length=100, blank=True, null=True, help_text='eg isMemberOf')
first_name = models.CharField(max_length=100, blank=True, null=True, help_text='eg gn')
last_name = models.CharField(max_length=100, blank=True, null=True, help_text='eg sn')
user_logo = models.CharField(max_length=100, blank=True, null=True, help_text='eg jpegPhoto')
role = models.CharField(max_length=100, blank=True, null=True, help_text='eduPersonPrimaryAffiliation')
verified_email = models.BooleanField(default=False, help_text='Mark email as verified')
email_authentication = models.BooleanField(default=False, help_text='Use email authentication too')
remove_from_groups = models.BooleanField(default=False, help_text='Automatically remove from groups')
save_saml_response_logs = models.BooleanField(default=True)
class Meta:
verbose_name = 'SAML Configuration'
verbose_name_plural = 'SAML Configurations'
unique_together = ['social_app', 'idp_id']
def __str__(self):
return f'SAML Config for {self.social_app.name} - {self.idp_id}'
def clean(self):
existing_conf = SAMLConfiguration.objects.filter(social_app=self.social_app)
if self.pk:
existing_conf = existing_conf.exclude(pk=self.pk)
if existing_conf.exists():
raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'})
super().clean()
@property
def saml_provider_settings(self):
# provide settings in a way for Social App SAML provider
provider_settings = {}
provider_settings["sp"] = {"entity_id": self.sp_metadata_url}
provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id}
provider_settings["attribute_mapping"] = {
"uid": self.uid,
"name": self.name,
"role": self.role,
"email": self.email,
"groups": self.groups,
"first_name": self.first_name,
"last_name": self.last_name,
}
provider_settings["email_verified"] = self.verified_email
provider_settings["email_authentication"] = self.email_authentication
return provider_settings

0
saml_auth/tests.py Normal file
View File

0
saml_auth/views.py Normal file
View File

951
static/jazzmin/css/main.css Normal file
View File

@ -0,0 +1,951 @@
/** Django-related improvements to AdminLTE UI **/
div.inline-related {
padding: 10px;
}
.form-row {
padding: 5px;
}
.help-block ul {
margin: 10px 0 0 15px;
padding: 0;
}
/** Fix bug of adminLTE, since django is using th headers in middle of table **/
.card-body.p-0 .table thead > tr > th:first-of-type,
.card-body.p-0 .table thead > tr > td:first-of-type,
.card-body.p-0 .table tfoot > tr > th:first-of-type,
.card-body.p-0 .table tfoot > tr > td:first-of-type,
.card-body.p-0 .table tbody > tr > th:first-of-type,
.card-body.p-0 .table tbody > tr > td:first-of-type {
padding-left: 0.75rem;
}
.card-body.p-0 .table thead > tr > th:last-of-type,
.card-body.p-0 .table thead > tr > td:last-of-type,
.card-body.p-0 .table tfoot > tr > th:last-of-type,
.card-body.p-0 .table tfoot > tr > td:last-of-type,
.card-body.p-0 .table tbody > tr > th:last-of-type,
.card-body.p-0 .table tbody > tr > td:last-of-type {
padding-right: 0.75rem;
}
.card-body.p-0 .table thead > tr > th:first-child,
.card-body.p-0 .table thead > tr > td:first-child,
.card-body.p-0 .table tfoot > tr > th:first-child,
.card-body.p-0 .table tfoot > tr > td:first-child,
.card-body.p-0 .table tbody > tr > th:first-child,
.card-body.p-0 .table tbody > tr > td:first-child {
padding-left: 1.5rem;
}
.card-body.p-0 .table thead > tr > th:last-child,
.card-body.p-0 .table thead > tr > td:last-child,
.card-body.p-0 .table tfoot > tr > th:last-child,
.card-body.p-0 .table tfoot > tr > td:last-child,
.card-body.p-0 .table tbody > tr > th:last-child,
.card-body.p-0 .table tbody > tr > td:last-child {
padding-right: 1.5rem;
}
[class*=sidebar-dark-] .nav-header {
margin-top: 1rem;
}
.nav-sidebar .nav-header {
font-size: 1.2rem !important
}
/* Table styles */
.table tr.form-row {
display: table-row;
}
.table td.action-checkbox {
width: 45px;
}
.table thead th {
color: #64748b;
border-bottom: 0;
}
.empty-form {
display: none !important;
}
.inline-related .tabular {
background-color: white;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
td.djn-td,
th.djn-th {
padding: 10px;
}
td.delete input {
margin: 10px;
}
tr.djn-tr>.original {
padding-left: 20px;
}
.hidden {
display: none;
}
/* Checkbox selection table header */
.djn-checkbox-select-all {
padding-right: 0 !important;
width: 0;
}
.object-tools {
padding: 0;
}
.object-tools li {
list-style: none;
margin: 0;
padding: 0;
}
.object-tools .historylink {
background-color: #3c8dbc;
width: 100%;
display: block;
padding: 5px;
text-align: center;
color: white;
}
.jazzmin-avatar {
font-size: 20px;
}
.related-widget-wrapper-link {
padding: 7px;
}
.related-widget-wrapper select {
width: initial;
/* Setting a width will make the *-related btns overflow */
height: auto;
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: .25rem;
box-shadow: inset 0 0 0 transparent;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.tab-pane {
overflow-x: auto;
}
table.dataTable thead .sorting::after,
table.dataTable thead .sorting_asc::after,
table.dataTable thead .sorting_desc::after,
table.dataTable thead .sorting_asc_disabled::after,
table.dataTable thead .sorting_desc_disabled::after {
right: 0.5em;
content: "\2193";
}
.select2-container {
min-width: 200px;
}
.select2-container .select2-selection--single {
border: 1px solid #ced4da !important;
min-height: 38px;
/* Center text inside */
display: flex !important;
align-items: center;
}
.select2-container--default .select2-selection--single {
border: 1px solid #ced4da;
}
.select2-container--default .select2-selection--single .select2-selection__arrow {
right: 5px !important;
top: unset !important;
}
.select2-results__option {
color: black;
}
.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] {
background-color: #0074f0 !important;
color: white !important;
}
.select2-container--default .select2-results__option--selected {
background-color: #ddd !important;
}
#changelist-search .form-group {
margin-bottom: .5em;
margin-right: .5em;
}
.table tbody tr th {
padding-left: .75rem;
}
.user-profile {
font-size: 2.4em;
}
.date-hierarchy {
margin-right: 8px;
display: block;
}
/* APP.CSS */
.form-group div .vTextField,
.form-group div .vLargeTextField,
.form-group div .vURLField,
.form-group div .vBigIntegerField,
.form-group div input[type="text"]
{
display: block;
width: 100%;
}
.vTextField,
.vLargeTextField,
.vURLField,
.vIntegerField,
.vBigIntegerField,
.vForeignKeyRawIdAdminField,
.vDateField,
.vTimeField,
input[type="number"],
input[type="text"]
{
height: calc(2.25rem + 2px);
padding: .375rem .75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ced4da;
border-radius: .25rem;
box-shadow: inset 0 0 0 transparent;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
.vDateField,
.vTimeField {
margin-bottom: 5px;
display: inline-block;
}
.vLargeTextField {
height: auto;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
border: 1px solid #ccc;
}
.date-icon:before,
.clock-icon:before {
display: inline-block;
font: normal normal normal 14px/1 FontAwesome !important;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
content: "\f073";
}
.clock-icon:before {
content: "\f017";
}
/* CALENDARS & CLOCKS */
.calendarbox,
.clockbox {
margin: 5px auto;
font-size: 12px;
width: 19em;
text-align: center;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
overflow: hidden;
position: relative;
}
.clockbox {
width: auto;
}
.calendar {
margin: 0;
padding: 0;
}
.calendar table {
margin: 0;
padding: 0;
border-collapse: collapse;
background: white;
width: 100%;
}
.calendar caption,
.calendarbox h2,
.clockbox h2 {
margin: 0;
text-align: center;
border-top: none;
background: #f5dd5d;
font-weight: 700;
font-size: 12px;
color: #333;
}
.clockbox h2 {
font-size: 16px;
padding: 5px;
}
.calendar th {
padding: 8px 5px;
background: #f8f8f8;
border-bottom: 1px solid #ddd;
font-weight: 400;
font-size: 12px;
text-align: center;
color: #666;
}
.calendar td {
font-weight: 400;
font-size: 12px;
text-align: center;
padding: 0;
border-top: 1px solid #eee;
border-bottom: none;
}
.calendar td.selected a {
background: #3C8DBC;
color: #fff !important;
}
.calendar td.nonday {
background: #f8f8f8;
}
.calendar td.today a {
font-weight: 700;
}
.calendar td a,
.timelist a {
display: block;
font-weight: 400;
padding: 6px;
text-decoration: none;
color: #444;
}
.calendar td a:focus,
.timelist a:focus,
.calendar td a:hover,
.timelist a:hover {
background: #3C8DBC;
color: white;
}
.calendar td a:active,
.timelist a:active {
background: #3C8DBC;
color: white;
}
.calendarnav {
font-size: 10px;
text-align: center;
color: #ccc;
margin: 0;
padding: 1px 3px;
}
.calendarnav a:link,
#calendarnav a:visited,
#calendarnav a:focus,
#calendarnav a:hover {
color: #999;
}
.calendar-shortcuts {
background: white;
font-size: 11px;
line-height: 11px;
border-top: 1px solid #eee;
padding: 8px 0;
color: #ccc;
}
.calendarbox .calendarnav-previous,
.calendarbox .calendarnav-next {
display: block;
position: absolute;
top: 8px;
width: 15px;
height: 15px;
text-indent: -9999px;
padding: 0;
}
.calendarnav-previous {
left: 10px;
background: url(../img/calendar-icons.svg) 0 0 no-repeat;
}
.calendarbox .calendarnav-previous:focus,
.calendarbox .calendarnav-previous:hover {
background-position: 0 -15px;
}
.calendarnav-next {
right: 10px;
background: url(../img/calendar-icons.svg) 0 -30px no-repeat;
}
.calendarbox .calendarnav-next:focus,
.calendarbox .calendarnav-next:hover {
background-position: 0 -45px;
}
.calendar-cancel {
margin: 0;
padding: 4px 0;
font-size: 12px;
background: #eee;
border-top: 1px solid #ddd;
color: #333;
}
.calendar-cancel:focus,
.calendar-cancel:hover {
background: #ddd;
}
.calendar-cancel a {
color: black;
display: block;
}
/* Selectors - This needs some work TODO */
.selector {
width: 100%;
float: left;
}
.selector select {
width: 100%;
height: 15em;
}
.selector-available,
.selector-chosen {
float: left;
width: 48%;
text-align: center;
margin-bottom: 5px;
}
.selector-available h2,
.selector-chosen h2 {
border: 1px solid #ccc;
font-size: 16px;
padding: 5px;
}
.selector-chosen h2 {
background: #007bff;
color: #fff;
}
.selector .selector-available h2 {
background: #f8f8f8;
color: #666;
}
.selector .selector-filter {
background: white;
border: 1px solid #ccc;
padding: 8px;
color: #999;
font-size: 10px;
margin: 0;
text-align: left;
}
.selector-filter input {
height: 24px;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
border: 1px solid #ccc;
margin-left: 0 !important;
}
.selector .selector-filter label,
.inline-group .aligned .selector .selector-filter label {
float: left;
margin: 0;
width: 18px;
height: 18px;
padding: 0;
overflow: hidden;
line-height: 1;
}
select#id_rbac_groups_from,
select#id_rbac_groups_to,
select#id_contributors_field_to,
select#id_contributors_field_from,
select#id_members_field_to,
select#id_members_field_from,
select#id_managers_field_to,
select#id_managers_field_from,
select#id_categories_to,
select#id_categories_from {
height: 285px !important;
}
/* Might need to import more rules from:
* https://github.com/django/django/blob/master/django/contrib/admin/static/admin/css/responsive.css
*/
.inline-group {
overflow: auto;
}
.selector .selector-available input {
width: 100%;
margin-left: 8px;
}
.selector ul.selector-chooser {
float: left;
width: 4%;
background-color: #eee;
border-radius: 10px;
margin: 10em 0 0;
padding: 0;
}
.selector-chooser li {
margin: 0;
padding: 3px;
list-style-type: none;
}
.selector select {
padding: 0 10px;
margin: 0 0 10px;
/*border-radius: 0 0 4px 4px;*/
;
}
.selector-add,
.selector-remove {
height: 16px;
display: block;
text-indent: -3000px;
overflow: hidden;
cursor: default;
opacity: 0.3;
}
.active.selector-add,
.active.selector-remove {
opacity: 1;
}
.active.selector-add:hover,
.active.selector-remove:hover {
cursor: pointer;
}
.selector-add {
background: url(../img/selector-icons.svg) 0 -96px no-repeat;
}
.active.selector-add:focus,
.active.selector-add:hover {
background-position: 0 -112px;
}
.selector-remove {
background: url(../img/selector-icons.svg) 0 -64px no-repeat;
}
.active.selector-remove:focus,
.active.selector-remove:hover {
background-position: 0 -80px;
}
a.selector-chooseall,
a.selector-clearall {
display: inline-block;
height: 16px;
text-align: left;
margin: 1px auto 3px;
overflow: hidden;
font-weight: bold;
line-height: 16px;
color: #666;
text-decoration: none;
opacity: 0.3;
}
a.active.selector-chooseall:focus,
a.active.selector-clearall:focus,
a.active.selector-chooseall:hover,
a.active.selector-clearall:hover {
color: #447e9b;
}
a.active.selector-chooseall,
a.active.selector-clearall {
opacity: 1;
}
a.active.selector-chooseall:hover,
a.active.selector-clearall:hover {
cursor: pointer;
}
a.selector-chooseall {
padding: 0 18px 0 0;
background: url(../img/selector-icons.svg) right -160px no-repeat;
cursor: default;
}
a.active.selector-chooseall:focus,
a.active.selector-chooseall:hover {
background-position: 100% -176px;
}
a.selector-clearall {
padding: 0 0 0 18px;
background: url(../img/selector-icons.svg) 0 -128px no-repeat;
cursor: default;
}
a.active.selector-clearall:focus,
a.active.selector-clearall:hover {
background-position: 0 -144px;
}
.selector .search-label-icon {
height: 0;
}
#user_form input[type="password"] {
width: 100%;
}
.control-label {
margin-top: 7px;
}
.help-block,
.timezonewarning {
font-size: .8em;
color: #859099;
font-style: italic;
}
.dashboard tbody tr:first-child td {
border-top: none;
}
.vTimeField {
margin-top: 10px;
}
.vTimeField,
.vDateField {
min-width: 200px;
}
.date-icon::before,
.clock-icon::before {
font-family: "Font Awesome 5 Free" !important;
}
.timelist li {
list-style-type: none;
}
.timelist {
margin: 0;
padding: 0;
}
body.no-sidebar .content-wrapper,
body.no-sidebar .main-footer,
body.no-sidebar .main-header {
margin-left: 0;
}
.vCheckboxLabel.inline {
vertical-align: top;
color: red;
margin-bottom: 0;
}
.inline-related .card-header>span {
float: right;
}
.ui-customiser .menu-items div {
width: 40px;
height: 20px;
border-radius: 25px;
margin-right: 10px;
margin-bottom: 10px;
opacity: 0.8;
cursor: pointer;
}
.ui-customiser select {
width: 100%;
height: auto;
padding: 6px 2px;
}
.control-sidebar-content label {
vertical-align: top;
}
.ui-customiser .menu-items div.inactive {
opacity: 0.3;
}
.ui-customiser .menu-items div.active {
opacity: 1;
border: 1px solid white;
}
.timeline-item {
word-break: break-word;
}
.navbar-nav .brand-link {
padding-top: 3px;
}
.breadcrumb {
background: transparent;
margin: 0;
}
.breadcrumb-item+.breadcrumb-item::before {
content: "\203A";
}
.login-box,
.register-box {
width: 500px;
max-width: 100%;
}
#jazzy-collapsible .collapsible-header:hover {
background: #007bff;
color: white;
}
#jazzy-collapsible .collapsible-header {
cursor: pointer;
}
#jazzy-carousel .carousel-indicators li {
background-color: #007bfe;
}
#jazzy-carousel .carousel-indicators {
position: initial;
}
form ul.radiolist li {
list-style-type: none;
}
form ul.radiolist label {
float: none;
display: inline;
}
form ul.radiolist input[type="radio"] {
margin: -2px 4px 0 0;
padding: 0;
}
form ul.inline {
margin-left: 0;
padding: 0;
}
form ul.inline li {
float: left;
padding-right: 7px;
}
.content-wrapper>.content {
padding: 1rem 2rem;
}
.navbar {
padding: .5rem 2rem;
}
.main-footer {
color: #869099;
padding: 1rem 2rem;
font-size: 14px;
}
.page-actions > a {
margin-right:0.25rem;
margin-left: 0.25rem;
}
#jazzy-actions.sticky-top {
top: 10px;
}
body.layout-navbar-fixed #jazzy-actions.sticky-top {
top: 67px;
}
/* stacked inlines */
a.inline-deletelink:hover {
background-color: #c82333;
border-color: #bd2130;
}
a.inline-deletelink {
float: right;
padding: 3px 5px;
margin: 10px;
background-color: #dc3545;
border-radius: .25rem;
color: white !important;
border: 1px solid #dc3545;
transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
/* end stacked inlines */
/* Support for django-mptt */
#result_list .field-tree_actions {
width: calc(40px + 2.25rem);
}
#result_list .field-tree_actions>div {
margin-top: 0;
}
/* End support for django-mptt */
/* modal tweaks */
.modal.modal-wide .modal-dialog {
width: 50%;
max-width: inherit;
}
.modal-wide .modal-body {
overflow-y: auto;
}
iframe.related-iframe {
width: 100%;
height: 450px;
}
/* Blur background when using modal */
.modal-open .wrapper {
-webkit-filter: blur(1px);
-moz-filter: blur(1px);
-o-filter: blur(1px);
-ms-filter: blur(1px);
filter: blur(1px);
}
/* end modal tweaks */
.control-sidebar {
overflow: hidden scroll;
}
/* tweaks to allow bootstrap styling */
body.jazzmin-login-page {
-ms-flex-align: center;
align-items: center;
display: -ms-flexbox;
display: flex;
-ms-flex-direction: column;
flex-direction: column;
height: 100vh;
-ms-flex-pack: center;
justify-content: center;
}
.callout {
color: black;
}
/* sidebar scrolling */
.layout-fixed #jazzy-sidebar {
top: 0;
bottom: 0;
/* Enable y scroll */
overflow-y: scroll;
/* May inherit scroll, so we need to explicitly hide */
overflow-x: hidden;
}
/* calculate height to fit content, we don't to enable scrolling if the content fits */
.layout-fixed #jazzy-sidebar .sidebar {
height: auto !important;
}
/* Hide scrollbar */
.layout-fixed #jazzy-sidebar {
scrollbar-width: none;
}
.layout-fixed #jazzy-sidebar::-webkit-scrollbar {
width: 0;
}
/* nav-item will overflow container in width if scrollbar is visible */
#jazzy-sidebar .nav-sidebar > .nav-item {
width: 100%;
}
/* tweeks for django-filer*/
.navigator-top-nav + #content-main {
float: left;
width: 100%;
}

View File

@ -0,0 +1,14 @@
<svg width="15" height="60" viewBox="0 0 1792 7168" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="previous">
<path d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="next">
<path d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#previous" x="0" y="0" fill="#333333" />
<use xlink:href="#previous" x="0" y="1792" fill="#000000" />
<use xlink:href="#next" x="0" y="3584" fill="#333333" />
<use xlink:href="#next" x="0" y="5376" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -0,0 +1,9 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/>
</g>
</defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg">
<path fill="#efb80b" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,34 @@
<svg width="16" height="192" viewBox="0 0 1792 21504" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<g id="up">
<path d="M1412 895q0-27-18-45l-362-362-91-91q-18-18-45-18t-45 18l-91 91-362 362q-18 18-18 45t18 45l91 91q18 18 45 18t45-18l189-189v502q0 26 19 45t45 19h128q26 0 45-19t19-45v-502l189 189q19 19 45 19t45-19l91-91q18-18 18-45zm252 1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="down">
<path d="M1412 897q0-27-18-45l-91-91q-18-18-45-18t-45 18l-189 189v-502q0-26-19-45t-45-19h-128q-26 0-45 19t-19 45v502l-189-189q-19-19-45-19t-45 19l-91 91q-18 18-18 45t18 45l362 362 91 91q18 18 45 18t45-18l91-91 362-362q18-18 18-45zm252-1q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="left">
<path d="M1408 960v-128q0-26-19-45t-45-19h-502l189-189q19-19 19-45t-19-45l-91-91q-18-18-45-18t-45 18l-362 362-91 91q-18 18-18 45t18 45l91 91 362 362q18 18 45 18t45-18l91-91q18-18 18-45t-18-45l-189-189h502q26 0 45-19t19-45zm256-64q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="right">
<path d="M1413 896q0-27-18-45l-91-91-362-362q-18-18-45-18t-45 18l-91 91q-18 18-18 45t18 45l189 189h-502q-26 0-45 19t-19 45v128q0 26 19 45t45 19h502l-189 189q-19 19-19 45t19 45l91 91q18 18 45 18t45-18l362-362 91-91q18-18 18-45zm251 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="clearall">
<path transform="translate(336, 336) scale(0.75)" d="M1037 1395l102-102q19-19 19-45t-19-45l-307-307 307-307q19-19 19-45t-19-45l-102-102q-19-19-45-19t-45 19l-454 454q-19 19-19 45t19 45l454 454q19 19 45 19t45-19zm627-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
<g id="chooseall">
<path transform="translate(336, 336) scale(0.75)" d="M845 1395l454-454q19-19 19-45t-19-45l-454-454q-19-19-45-19t-45 19l-102 102q-19 19-19 45t19 45l307 307-307 307q-19 19-19 45t19 45l102 102q19 19 45 19t45-19zm819-499q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/>
</g>
</defs>
<use xlink:href="#up" x="0" y="0" fill="#666666" />
<use xlink:href="#up" x="0" y="1792" fill="#447e9b" />
<use xlink:href="#down" x="0" y="3584" fill="#666666" />
<use xlink:href="#down" x="0" y="5376" fill="#447e9b" />
<use xlink:href="#left" x="0" y="7168" fill="#666666" />
<use xlink:href="#left" x="0" y="8960" fill="#447e9b" />
<use xlink:href="#right" x="0" y="10752" fill="#666666" />
<use xlink:href="#right" x="0" y="12544" fill="#447e9b" />
<use xlink:href="#clearall" x="0" y="14336" fill="#666666" />
<use xlink:href="#clearall" x="0" y="16128" fill="#447e9b" />
<use xlink:href="#chooseall" x="0" y="17920" fill="#666666" />
<use xlink:href="#chooseall" x="0" y="19712" fill="#447e9b" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,151 @@
(function($) {
'use strict';
function FixSelectorHeight() {
$('.selector .selector-chosen').each(function () {
let selector_chosen = $(this);
let selector_available = selector_chosen.siblings('.selector-available');
let selector_chosen_select = selector_chosen.find('select').first();
let selector_available_select = selector_available.find('select').first();
let selector_available_filter = selector_available.find('p.selector-filter').first();
selector_chosen_select.height(selector_available_select.height() + selector_available_filter.outerHeight());
selector_chosen_select.css('border-top', selector_chosen_select.css('border-bottom'));
});
}
function handleCarousel($carousel) {
const errors = $('.errorlist li', $carousel);
const hash = document.location.hash;
// If we have errors, open that tab first
if (errors.length) {
const errorCarousel = errors.eq(0).closest('.carousel-item');
$carousel.carousel(errorCarousel.data('carouselid'));
$('.carousel-fieldset-label', $carousel).text(errorCarousel.data()["label"]);
} else if (hash) {
// If we have a tab hash, open that
const activeCarousel = $('.carousel-item[data-target="' + hash + '"]', $carousel);
$carousel.carousel(activeCarousel.data()["carouselid"]);
$('.carousel-fieldset-label', $carousel).text(activeCarousel.data()["label"]);
}
// Update page hash/history on slide
$carousel.on('slide.bs.carousel', function (e) {
FixSelectorHeight();
// call resize in change view after tab switch
window.dispatchEvent(new Event('resize'));
if (e.relatedTarget.dataset.hasOwnProperty("label")) {
$('.carousel-fieldset-label', $carousel).text(e.relatedTarget.dataset.label);
}
const hash = e.relatedTarget.dataset.target;
if (history.pushState) {
history.pushState(null, null, hash);
} else {
location.hash = hash;
}
});
}
function handleTabs($tabs) {
const errors = $('.change-form .errorlist li');
const hash = document.location.hash;
// If we have errors, open that tab first
if (errors.length) {
const tabId = errors.eq(0).closest('.tab-pane').attr('id');
$('a[href="#' + tabId + '"]').tab('show');
} else if (hash) {
// If we have a tab hash, open that
$('a[href="' + hash + '"]', $tabs).tab('show');
}
// Change hash for page-reload
$('a', $tabs).on('shown.bs.tab', function (e) {
FixSelectorHeight();
// call resize in change view after tab switch
window.dispatchEvent(new Event('resize'));
e.preventDefault();
if (history.pushState) {
history.pushState(null, null, e.target.hash);
} else {
location.hash = e.target.hash;
}
});
}
function handleCollapsible($collapsible) {
const errors = $('.errorlist li', $collapsible);
const hash = document.location.hash;
// If we have errors, open that tab first
if (errors.length) {
$('.panel-collapse', $collapsible).collapse('hide');
errors.eq(0).closest('.panel-collapse').collapse('show');
} else if (hash) {
// If we have a tab hash, open that
$('.panel-collapse', $collapsible).collapse('hide');
$(hash, $collapsible).collapse('show');
}
// Change hash for page-reload
$collapsible.on('shown.bs.collapse', function (e) {
FixSelectorHeight();
// call resize in change view after tab switch
window.dispatchEvent(new Event('resize'));
if (history.pushState) {
history.pushState(null, null, '#' + e.target.id);
} else {
location.hash = '#' + e.target.id;
}
});
}
function applySelect2() {
// Apply select2 to any select boxes that don't yet have it
// and are not part of the django's empty-form inline
const noSelect2 = '.empty-form select, .select2-hidden-accessible, .selectfilter, .selector-available select, .selector-chosen select, select[data-autocomplete-light-function=select2]';
$('select').not(noSelect2).select2({ width: 'element' });
}
$(document).ready(function () {
const $carousel = $('#content-main form #jazzy-carousel');
const $tabs = $('#content-main form #jazzy-tabs');
const $collapsible = $('#content-main form #jazzy-collapsible');
// Ensure all raw_id_fields have the search icon in them
$('.related-lookup').append('<i class="fa fa-search"></i>');
// Style the inline fieldset button
$('.inline-related fieldset.module .add-row a').addClass('btn btn-sm btn-default float-right');
$('div.add-row>a').addClass('btn btn-sm btn-default float-right');
// Ensure we preserve the tab the user was on using the url hash, even on page reload
if ($tabs.length) { handleTabs($tabs); }
else if ($carousel.length) { handleCarousel($carousel); }
else if ($collapsible.length) { handleCollapsible($collapsible); }
applySelect2();
$('body').on('change', '.related-widget-wrapper select', function(e) {
const event = $.Event('django:update-related');
$(this).trigger(event);
if (!event.isDefaultPrevented() && typeof(window.updateRelatedObjectLinks) !== 'undefined') {
updateRelatedObjectLinks(this);
}
});
});
// Apply select2 to all select boxes when new inline row is created
django.jQuery(document).on('formset:added', applySelect2);
})(jQuery);

View File

@ -0,0 +1,64 @@
(function($) {
'use strict';
$.fn.search_filters = function () {
$(this).change(function () {
const $field = $(this);
const $option = $field.find('option:selected');
const select_name = $option.data('name');
if (select_name) {
$field.attr('name', select_name);
} else {
$field.removeAttr('name');
}
});
$(this).trigger('change');
};
function getMinimuInputLength(element) {
return window.filterInputLength[element.data('name')] ?? window.filterInputLengthDefault;
}
function searchFilters() {
// Make search filters select2 and ensure they work for filtering
const $ele = $('.search-filter');
$ele.search_filters();
$ele.each(function () {
const $this = $(this);
$this.select2({ width: '100%', minimumInputLength: getMinimuInputLength($this) });
});
// Use select2 for mptt dropdowns
const $mptt = $('.search-filter-mptt');
if ($mptt.length) {
$mptt.search_filters();
$mptt.select2({
width: '100%',
minimumInputLength: getMinimuInputLength($mptt),
templateResult: function (data) {
// https://stackoverflow.com/questions/30820215/selectable-optgroups-in-select2#30948247
// rewrite templateresult for build tree hierarchy
if (!data.element) {
return data.text;
}
const $element = $(data.element);
let $wrapper = $('<span></span>');
$wrapper.attr('style', $($element[0]).attr('style'));
$wrapper.text(data.text);
return $wrapper;
},
});
}
}
$(document).ready(function () {
// Ensure all raw_id_fields have the search icon in them
$('.related-lookup').append('<i class="fa fa-search"></i>')
// Allow for styling of selects
$('.actions select').addClass('form-control').select2({ width: 'element' });
searchFilters();
});
})(jQuery);

67
static/jazzmin/js/main.js Normal file
View File

@ -0,0 +1,67 @@
(function($) {
'use strict';
function setCookie(key, value) {
const expires = new Date();
expires.setTime(expires.getTime() + (value * 24 * 60 * 60 * 1000));
document.cookie = key + '=' + value + ';expires=' + expires.toUTCString() + '; SameSite=Strict;path=/';
}
function getCookie(key) {
const keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)');
return keyValue ? keyValue[2] : null;
}
function handleMenu() {
$('[data-widget=pushmenu]').bind('click', function () {
const menuClosed = getCookie('jazzy_menu') === 'closed';
if (!menuClosed) {
setCookie('jazzy_menu', 'closed');
} else {
setCookie('jazzy_menu', 'open');
}
});
}
function setActiveLinks() {
/*
Set the currently active menu item based on the current url, or failing that, find the parent
item from the breadcrumbs
*/
const url = window.location.pathname;
const $breadcrumb = $('.breadcrumb a').last();
const $link = $('a[href="' + url + '"]');
const $parent_link = $('a[href="' + $breadcrumb.attr('href') + '"]');
if ($link.length) {
$link.addClass('active');
} else if ($parent_link.length) {
$parent_link.addClass('active');
};
const $a_active = $('a.nav-link.active');
const $main_li_parent = $a_active.closest('li.nav-item.has-treeview');
const $ul_child = $main_li_parent.children('ul');
$ul_child.show();
$main_li_parent.addClass('menu-is-opening menu-open');
};
$(document).ready(function () {
// Set active status on links
setActiveLinks()
// When we use the menu, store its state in a cookie to preserve it
handleMenu();
// Add minimal changelist styling to templates that we have been unable to override (e.g MPTT)
// Needs to be here and not in change_list.js because this is the only JS we are guaranteed to run
// (as its included in base.html)
const $changeListTable = $('#changelist .results table');
if ($changeListTable.length && !$changeListTable.hasClass('table table-striped')) {
$changeListTable.addClass('table table-striped');
};
});
})(jQuery);

View File

@ -0,0 +1,188 @@
(function($) {
'use strict';
let relatedModalCounter = 0;
function checkIfInIframe() {
return window.top !== window.self;
}
// create the function that will close the modal
function dismissModal() {
if (checkIfInIframe()) {
const parentWindow = window.parent;
parentWindow.dismissModal();
return;
}
$('.related-modal-' + relatedModalCounter).modal('hide');
relatedModalCounter-=1;
}
// create the function that will show the modal
function showModal(title, body, e) {
if (checkIfInIframe()) {
const parentWindow = window.parent;
parentWindow.showModal(title, body, e);
return;
}
relatedModalCounter+=1;
$.showModal({
title: title,
body: body,
backdrop: false,
modalDialogClass: "modal-dialog-centered modal-lg",
modalClass: "fade modal-wide related-modal-" + relatedModalCounter,
onDispose: function() {
// add focus to the previous modal (if exists) when the current one is closed
var lastModal = $("div[class*='related-modal-']").last();
if (lastModal) {
lastModal.focus();
}
}
});
const modalEl = $("div[class*='related-modal-']");
const iframeEl = modalEl.find('#related-modal-iframe');
if (e.data.lookup === true) {
// set current window as iframe opener because
// the callback is called on the opener window
iframeEl.on('load', function() {
const iframeObj = $(this).get(0);
const iframeWindow = iframeObj.contentWindow;
iframeWindow.opener = window;
});
}
}
function dismissRelatedLookupModal(win, chosenId) {
const windowName = win.name;
const widgetName = windowName.replace(/^(change|add|delete|lookup)_/, '');
let widgetEl;
if (checkIfInIframe) {
// select second to last iframe in the main parent document
const secondLastIframe = $('iframe.related-iframe', win.parent.document).eq(-2);
let documentContext;
// if second to last iframe exists get its contents
if (secondLastIframe.length) {
documentContext = secondLastIframe.contents();
// else get main parent document
} else {
documentContext = $(win.parent.document);
}
// find and select widget from the specified document context
widgetEl = documentContext.find('#' + widgetName);
// else select widget from the main document
} else {
widgetEl = $('#' + widgetName);
}
const widgetVal = widgetEl.val();
if (widgetEl.hasClass('vManyToManyRawIdAdminField') && Boolean(widgetVal)) {
widgetEl.val(widgetVal + ', ' + chosenId);
} else {
widgetEl.val(chosenId);
}
dismissModal();
}
// assign functions to global variables
window.dismissRelatedObjectModal = dismissModal;
window.dismissRelatedLookupPopup = dismissRelatedLookupModal;
window.showModal = showModal;
function presentRelatedObjectModal(e) {
let linkEl = $(this);
let href = (linkEl.attr('href') || '');
if (href === '') {
return;
}
// open the popup as modal
e.preventDefault();
e.stopImmediatePropagation();
// remove focus from clicked link
linkEl.blur();
// use the clicked link id as iframe name
// it will be available as window.name in the loaded iframe
let iframeName = linkEl.attr('id');
let iframeSrc = href;
const modalTitle = linkEl.attr('title');
if (e.data.lookup !== true) {
// browsers stop loading nested iframes having the same src url
// create a random parameter and append it to the src url to prevent it
// this workaround doesn't work with related lookup url
let iframeSrcRandom = String(Math.round(Math.random() * 999999));
if (iframeSrc.indexOf('?') === -1) {
iframeSrc += '?_modal=' + iframeSrcRandom;
} else {
iframeSrc += '&_modal=' + iframeSrcRandom;
}
}
if (iframeSrc.indexOf('_popup=1') === -1) {
if (iframeSrc.indexOf('?') === -1) {
iframeSrc += '?_popup=1';
} else {
iframeSrc += '&_popup=1';
}
}
// build the iframe html
let iframeHTML = '<iframe id="related-modal-iframe" name="' + iframeName + '" src="' + iframeSrc + '" frameBorder="0" class="related-iframe"></iframe>';
// the modal css class
let iframeInternalModalClass = 'related-modal';
// if the current window is inside an iframe, it means that it is already in a modal,
// append an additional css class to the modal to offer more customization
if (window.top !== window.self) {
iframeInternalModalClass += ' related-modal__nested';
}
// open the modal using dynamic bootstrap modal
showModal(modalTitle, iframeHTML, e);
return false;
}
// listen click events on related links
function presentRelatedObjectModalOnClickOn(selector, lookup) {
let el = $(selector);
el.removeAttr('onclick');
el.unbind('click');
el.click({lookup: lookup}, presentRelatedObjectModal);
}
function init() {
presentRelatedObjectModalOnClickOn('a.related-widget-wrapper-link', false);
// raw_id_fields support
presentRelatedObjectModalOnClickOn('a.related-lookup', true);
// django-dynamic-raw-id support - #61
// https://github.com/lincolnloop/django-dynamic-raw-id
presentRelatedObjectModalOnClickOn('a.dynamic_raw_id-related-lookup', true);
}
$(document).ready(function(){
init()
});
django.jQuery(document).on('formset:added', init);
})(jQuery);

View File

@ -0,0 +1,343 @@
(function ($) {
'use strict';
const $body = $('body');
const $footer = $('footer');
const $sidebar_ul = $('aside#jazzy-sidebar nav ul:first-child');
const $sidebar = $('aside#jazzy-sidebar');
const $navbar = $('nav#jazzy-navbar');
const $logo = $('#jazzy-logo');
const $actions = $('#jazzy-actions');
const buttons = [
"primary",
"secondary",
"info",
"warning",
"danger",
"success",
]
const darkThemes = ["darkly", "cyborg", "slate", "solar", "superhero"]
window.ui_changes = window.ui_changes || {'button_classes': {}};
function miscListeners() {
$('#footer-fixed').on('click', function () {
$body.toggleClass('layout-footer-fixed');
if (this.checked) {
$('#layout-boxed:checked').click();
}
window.ui_changes['footer_fixed'] = this.checked;
});
$('#layout-boxed').on('click', function () {
$body.toggleClass('layout-boxed');
// We cannot combine these options with layout boxed
if (this.checked) {
$('#navbar-fixed:checked').click();
$('#footer-fixed:checked').click();
}
window.ui_changes['layout_boxed'] = this.checked;
});
$('#actions-fixed').on('click', function () {
$actions.toggleClass('sticky-top');
window.ui_changes['actions_sticky_top'] = this.checked;
});
// Colour pickers
$('#accent-colours div').on('click', function () {
$(this).removeClass('inactive').addClass('active').parent().find(
'div'
).not(this).removeClass('active').addClass('inactive');
const newClasses = $(this).data('classes');
$body.removeClass(function (index, className) {
return (className.match(/(^|\s)accent-\S+/g) || []).join(' ');
}).addClass(newClasses);
window.ui_changes['accent'] = newClasses;
});
$('#brand-logo-variants div').on('click', function () {
$(this).removeClass('inactive').addClass('active').parent().find(
'div'
).not(this).removeClass('active').addClass('inactive');
let newClasses = $(this).data('classes');
$logo.removeClass(function (index, className) {
return (className.match(/(^|\s)navbar-\S+/g) || []).join(' ');
}).addClass(newClasses);
if (newClasses === "") {
newClasses = false;
$(this).parent().find('div').removeClass('active inactive');
}
window.ui_changes['brand_colour'] = newClasses;
});
// show code
$("#codeBox").on('show.bs.modal', function () {
$('.modal-body code', this).html(
'JAZZMIN_UI_TWEAKS = ' + JSON.stringify(
window.ui_changes, null, 4
).replace(
/true/g, 'True'
).replace(
/false/g, 'False'
).replace(
/null/g, 'None'
)
);
});
}
function themeSpecificTweaks(theme) {
if (darkThemes.indexOf(theme) > -1) {
$('#navbar-variants .bg-dark').click();
$("#jazzmin-btn-style-primary").val('btn-primary').change();
$("#jazzmin-btn-style-secondary").val('btn-secondary').change();
$body.addClass('dark-mode');
} else {
$('#navbar-variants .bg-white').click();
$("#jazzmin-btn-style-primary").val('btn-outline-primary').change();
$("#jazzmin-btn-style-secondary").val('btn-outline-secondary').change();
$body.removeClass('dark-mode');
}
}
function themeChooserListeners() {
// Theme chooser (standard)
$("#jazzmin-theme-chooser").on('change', function () {
let $themeCSS = $('#jazzmin-theme');
// If we are using the default theme, there will be no theme css, just the bundled one in adminlte
if (!$themeCSS.length) {
const staticSrc = $('#adminlte-css').attr('href').split('vendor')[0]
$themeCSS = $('<link>').attr({
'href': staticSrc + 'vendor/bootswatch/default/bootstrap.min.css',
'rel': 'stylesheet',
'id': 'jazzmin-theme'
}).appendTo('head');
}
const currentSrc = $themeCSS.attr('href');
const currentTheme = currentSrc.split('/')[4];
let newTheme = $(this).val();
$themeCSS.attr('href', currentSrc.replace(currentTheme, newTheme));
$body.removeClass (function (index, className) {
return (className.match (/(^|\s)theme-\S+/g) || []).join(' ');
});
$body.addClass('theme-' + newTheme);
themeSpecificTweaks(newTheme);
window.ui_changes['theme'] = newTheme;
});
// Theme chooser (dark mode)
$("#jazzmin-dark-mode-theme-chooser").on('change', function () {
let $themeCSS = $('#jazzmin-dark-mode-theme');
// If we are using the default theme, there will be no theme css, just the bundled one in adminlte
if (this.value === "") {
$themeCSS.remove();
window.ui_changes['dark_mode_theme'] = null;
return
}
if (!$themeCSS.length) {
const staticSrc = $('#adminlte-css').attr('href').split('vendor')[0]
$themeCSS = $('<link>').attr({
'href': staticSrc + 'vendor/bootswatch/darkly/bootstrap.min.css',
'rel': 'stylesheet',
'id': 'jazzmin-dark-mode-theme',
'media': '(prefers-color-scheme: dark)'
}).appendTo('head');
}
const currentSrc = $themeCSS.attr('href');
const currentTheme = currentSrc.split('/')[4];
const newTheme = $(this).val();
$themeCSS.attr('href', currentSrc.replace(currentTheme, newTheme));
themeSpecificTweaks(newTheme);
window.ui_changes['dark_mode_theme'] = newTheme;
});
}
function navBarTweaksListeners() {
$('#navbar-fixed').on('click', function () {
$body.toggleClass('layout-navbar-fixed');
if (this.checked) {$('#layout-boxed:checked').click();}
window.ui_changes['navbar_fixed'] = this.checked;
});
$('#no-navbar-border').on('click', function () {
$navbar.toggleClass('border-bottom-0');
window.ui_changes['no_navbar_border'] = $navbar.hasClass('border-bottom-0');
});
// Colour picker
$('#navbar-variants div').on('click', function () {
$(this).removeClass('inactive').addClass('active').parent().find(
'div'
).not(this).removeClass('active').addClass('inactive');
const newClasses = $(this).data('classes');
$navbar.removeClass(function (index, className) {
return (className.match(/(^|\s)navbar-\S+/g) || []).join(' ');
}).addClass('navbar-expand ' + newClasses);
window.ui_changes['navbar'] = newClasses;
});
}
function sideBarTweaksListeners() {
$('#sidebar-nav-flat-style').on('click', function () {
$sidebar_ul.toggleClass('nav-flat');
window.ui_changes['sidebar_nav_flat_style'] = this.checked;
});
$('#sidebar-nav-legacy-style').on('click', function () {
$sidebar_ul.toggleClass('nav-legacy');
window.ui_changes['sidebar_nav_legacy_style'] = this.checked;
});
$('#sidebar-nav-compact').on('click', function () {
$sidebar_ul.toggleClass('nav-compact');
window.ui_changes['sidebar_nav_compact_style'] = this.checked;
});
$('#sidebar-nav-child-indent').on('click', function () {
$sidebar_ul.toggleClass('nav-child-indent');
window.ui_changes['sidebar_nav_child_indent'] = this.checked;
});
$('#main-sidebar-disable-hover-focus-auto-expand').on('click', function () {
$sidebar.toggleClass('sidebar-no-expand');
window.ui_changes['sidebar_disable_expand'] = this.checked;
});
$('#sidebar-fixed').on('click', function () {
$body.toggleClass('layout-fixed');
window.ui_changes['sidebar_fixed'] = this.checked;
});
// Colour pickers
$('#dark-sidebar-variants div, #light-sidebar-variants div').on('click', function () {
$(this).removeClass('inactive').addClass('active').parent().find(
'div'
).not(this).removeClass('active').addClass('inactive');
const newClasses = $(this).data('classes');
$sidebar.removeClass(function (index, className) {
return (className.match(/(^|\s)sidebar-[\S|-]+/g) || []).join(' ');
}).addClass(newClasses);
window.ui_changes['sidebar'] = newClasses.trim();
});
}
function smallTextListeners() {
$('#navbar-small-text').on('click', function () {
$navbar.toggleClass('text-sm');
window.ui_changes['navbar_small_text'] = this.checked;
});
$('#brand-small-text').on('click', function () {
$logo.toggleClass('text-sm');
window.ui_changes['brand_small_text'] = this.checked;
});
$('#body-small-text').on('click', function () {
$body.toggleClass('text-sm');
window.ui_changes['body_small_text'] = this.checked;
const $smallTextControls = $('#navbar-small-text, #brand-small-text, #footer-small-text, #sidebar-nav-small-text');
if (this.checked) {
window.ui_changes['navbar_small_text'] = false;
window.ui_changes['brand_small_text'] = false;
window.ui_changes['footer_small_text'] = false;
window.ui_changes['sidebar_nav_small_text'] = false;
$smallTextControls.prop({'checked': false, 'disabled': 'disabled'});
} else {
$smallTextControls.prop({'checked': false, 'disabled': ''});
}
});
$('#footer-small-text').on('click', function () {
$footer.toggleClass('text-sm');
window.ui_changes['footer_small_text'] = this.checked;
});
$('#sidebar-nav-small-text').on('click', function () {
$sidebar_ul.toggleClass('text-sm');
window.ui_changes['sidebar_nav_small_text'] = this.checked;
});
}
function buttonStyleListeners() {
buttons.forEach(function(btn) {
$("#jazzmin-btn-style-" + btn).on('change', function () {
const btnClasses = ['btn-' + btn, 'btn-outline-' + btn];
const selectorClasses = '.btn-' + btn + ', .btn-outline-' + btn;
$(selectorClasses).removeClass(btnClasses).addClass(this.value);
window.ui_changes['button_classes'][btn] = this.value;
});
});
}
function setFromExisting() {
$('#jazzmin-theme-chooser').val(window.ui_changes['theme']);
$('#jazzmin-dark-mode-theme-chooser').val(window.ui_changes['dark_mode_theme']);
$('#theme-condition').val(window.ui_changes['theme_condition']);
$('#body-small-text').get(0).checked = window.ui_changes['body_small_text'];
$('#footer-small-text').get(0).checked = window.ui_changes['footer_small_text'];
$('#sidebar-nav-small-text').get(0).checked = window.ui_changes['sidebar_nav_small_text'];
$('#sidebar-nav-legacy-style').get(0).checked = window.ui_changes['sidebar_nav_legacy_style'];
$('#sidebar-nav-compact').get(0).checked = window.ui_changes['sidebar_nav_compact_style'];
$('#sidebar-nav-child-indent').get(0).checked = window.ui_changes['sidebar_nav_child_indent'];
$('#main-sidebar-disable-hover-focus-auto-expand').get(0).checked = window.ui_changes['sidebar_disable_expand'];
$('#no-navbar-border').get(0).checked = window.ui_changes['no_navbar_border'];
$('#navbar-small-text').get(0).checked = window.ui_changes['navbar_small_text'];
$('#brand-small-text').get(0).checked = window.ui_changes['brand_small_text'];
// deactivate colours
$('#navbar-variants div, #accent-colours div, #dark-sidebar-variants div, #light-sidebar-variants div, #brand-logo-variants div').addClass('inactive');
// set button styles
buttons.forEach(function(btn) {
$("#jazzmin-btn-style-" + btn).val(window.ui_changes['button_classes'][btn]);
});
// set colours
$('#navbar-variants div[data-classes="' + window.ui_changes['navbar'] + '"]').addClass('active');
$('#accent-colours div[data-classes="' + window.ui_changes['accent'] + '"]').addClass('active');
$('#dark-sidebar-variants div[data-classes="' + window.ui_changes['sidebar'] + '"]').addClass('active');
$('#light-sidebar-variants div[data-classes="' + window.ui_changes['sidebar'] + '"]').addClass('active');
$('#brand-logo-variants div[data-classes="' + window.ui_changes['brand_colour'] + '"]').addClass('active');
}
/*
Don't call if it is inside an iframe
*/
if (!$body.hasClass("popup")) {
setFromExisting();
themeChooserListeners();
miscListeners();
navBarTweaksListeners();
sideBarTweaksListeners();
smallTextListeners();
buttonStyleListeners();
}
})(jQuery);

View File

@ -0,0 +1 @@
!function(o){"use strict";var s=0;function i(t){for(var e in this.props={title:"",body:"",footer:"",modalClass:"fade",modalDialogClass:"",options:null,onCreate:null,onDispose:null,onSubmit:null},t)this.props[e]=t[e];this.id="bootstrap-show-modal-"+s,s++,this.show()}i.prototype.createContainerElement=function(){var t=this;this.element=document.createElement("div"),this.element.id=this.id,this.element.setAttribute("class","modal "+this.props.modalClass),this.element.setAttribute("tabindex","-1"),this.element.setAttribute("role","dialog"),this.element.setAttribute("aria-labelledby",this.id),this.element.innerHTML='<div class="modal-dialog '+this.props.modalDialogClass+'" role="document"><div class="modal-content"><div class="modal-header"><h5 class="modal-title"></h5><button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button></div><div class="modal-body"></div><div class="modal-footer"></div></div></div>',document.body.appendChild(this.element),this.titleElement=this.element.querySelector(".modal-title"),this.bodyElement=this.element.querySelector(".modal-body"),this.footerElement=this.element.querySelector(".modal-footer"),o(this.element).on("hidden.bs.modal",function(){t.dispose()}),this.props.onCreate&&this.props.onCreate(this)},i.prototype.show=function(){this.element?o(this.element).modal("show"):(this.createContainerElement(),this.props.options?o(this.element).modal(this.props.options):o(this.element).modal()),this.props.title?(o(this.titleElement).show(),this.titleElement.innerHTML=this.props.title):o(this.titleElement).hide(),this.props.body?(o(this.bodyElement).show(),this.bodyElement.innerHTML=this.props.body):o(this.bodyElement).hide(),this.props.footer?(o(this.footerElement).show(),this.footerElement.innerHTML=this.props.footer):o(this.footerElement).hide()},i.prototype.hide=function(){o(this.element).modal("hide")},i.prototype.dispose=function(){o(this.element).modal("dispose"),document.body.removeChild(this.element),this.props.onDispose&&this.props.onDispose(this)},o.extend({showModal:function(t){if(t.buttons){var e,o="";for(e in t.buttons){o+='<button type="button" class="btn btn-primary" data-value="'+e+'" data-dismiss="modal">'+t.buttons[e]+"</button>"}t.footer=o}return new i(t)},showAlert:function(t){return t.buttons={OK:"OK"},this.showModal(t)},showConfirm:function(t){return t.footer='<button class="btn btn-secondary btn-false btn-cancel">'+t.textFalse+'</button><button class="btn btn-primary btn-true">'+t.textTrue+"</button>",t.onCreate=function(e){o(e.element).on("click",".btn",function(t){t.preventDefault(),e.hide(),e.props.onSubmit(-1!==t.target.getAttribute("class").indexOf("btn-true"),e)})},this.showModal(t)}})}(jQuery);

View File

@ -0,0 +1,2 @@
group_id,category_id
all_students_FIT,9b0c545a-0615-49ff-892a-d2401224a27c
1 group_id category_id
2 all_students_FIT 9b0c545a-0615-49ff-892a-d2401224a27c

View File

@ -0,0 +1,2 @@
group_id,name
all_students_FIT,All Students FIT
1 group_id name
2 all_students_FIT All Students FIT

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
static/vendor/adminlte/img/icons.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

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

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

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

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

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

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More