feat: RBAC + SAML support
@ -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"]
|
||||
|
@ -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
|
||||
|
0
admin_customizations/__init__.py
Normal file
0
admin_customizations/admin.py
Normal file
86
admin_customizations/apps.py
Normal 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
|
0
admin_customizations/migrations/__init__.py
Normal file
0
admin_customizations/models.py
Normal file
0
admin_customizations/tests.py
Normal file
0
admin_customizations/views.py
Normal 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 = [
|
||||
|
@ -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
|
||||
|
@ -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
@ -0,0 +1 @@
|
||||
VERSION = "5.0.0"
|
@ -1,5 +0,0 @@
|
||||
from pytest_factoryboy import register
|
||||
|
||||
from tests.users.factories import UserFactory
|
||||
|
||||
register(UserFactory)
|
75
deic_setup_notes.md
Normal 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
|
@ -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:
|
||||
|
@ -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.
|
||||
|
||||
|
128
files/admin.py
@ -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"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
19
files/migrations/0005_alter_category_uid.py
Normal 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),
|
||||
),
|
||||
]
|
17
files/migrations/0006_alter_category_title.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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"))
|
||||
|
@ -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})
|
||||
|
@ -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>
|
||||
|
0
identity_providers/__init__.py
Normal file
360
identity_providers/admin.py
Normal 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"
|
6
identity_providers/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IdentityProvidersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'identity_providers'
|
69
identity_providers/forms.py
Normal 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.")
|
87
identity_providers/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
27
identity_providers/migrations/0002_loginoption.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
@ -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(),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
0
identity_providers/migrations/__init__.py
Normal file
125
identity_providers/models.py
Normal 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
|
0
identity_providers/tests.py
Normal file
0
identity_providers/views.py
Normal file
0
rbac/__init__.py
Normal file
212
rbac/admin.py
Normal 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
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RbacConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rbac'
|
63
rbac/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
19
rbac/migrations/0002_alter_rbacgroup_uid.py
Normal 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),
|
||||
),
|
||||
]
|
0
rbac/migrations/__init__.py
Normal file
96
rbac/models.py
Normal 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
0
rbac/views.py
Normal 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
153
saml_auth/adapter.py
Normal 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
@ -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
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SamlAuthConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'saml_auth'
|
0
saml_auth/custom/__init__.py
Normal file
61
saml_auth/custom/provider.py
Normal 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
@ -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
@ -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
@ -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()
|
44
saml_auth/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
0
saml_auth/migrations/__init__.py
Normal file
72
saml_auth/models.py
Normal 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
0
saml_auth/views.py
Normal file
951
static/jazzmin/css/main.css
Normal 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%;
|
||||
}
|
14
static/jazzmin/img/calendar-icons.svg
Normal 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 |
23
static/jazzmin/img/default-log.svg
Normal file
After Width: | Height: | Size: 218 KiB |
BIN
static/jazzmin/img/default.jpg
Normal file
After Width: | Height: | Size: 6.9 KiB |
9
static/jazzmin/img/icon-calendar.svg
Normal 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 |
3
static/jazzmin/img/icon-changelink.svg
Normal 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 |
34
static/jazzmin/img/selector-icons.svg
Normal 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 |
151
static/jazzmin/js/change_form.js
Normal 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);
|
64
static/jazzmin/js/change_list.js
Normal 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
@ -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);
|
188
static/jazzmin/js/related-modal.js
Normal 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);
|
343
static/jazzmin/js/ui-builder.js
Normal 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);
|
1
static/jazzmin/plugins/bootstrap-show-modal/bootstrap-show-modal.min.js
vendored
Normal 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">×</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);
|
2
static/templates/category_mapping.csv
Normal file
@ -0,0 +1,2 @@
|
||||
group_id,category_id
|
||||
all_students_FIT,9b0c545a-0615-49ff-892a-d2401224a27c
|
|
2
static/templates/group_mapping.csv
Normal file
@ -0,0 +1,2 @@
|
||||
group_id,name
|
||||
all_students_FIT,All Students FIT
|
|
12
static/vendor/adminlte/css/adminlte.min.css
vendored
Normal file
1
static/vendor/adminlte/css/adminlte.min.css.map
vendored
Normal file
BIN
static/vendor/adminlte/img/AdminLTELogo.png
vendored
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
static/vendor/adminlte/img/icons.png
vendored
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
static/vendor/adminlte/img/user2-160x160.jpg
vendored
Normal file
After Width: | Height: | Size: 5.2 KiB |