diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1809222a8d07d0745c4d9e4bcde8f073cd34a62a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.pyc +*.swp +.venv +.vscode +venv +.DS_Store +db.sqlite3 +*.mo +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9382b36f9f3f80e5d44866ff387f8e2ec02a2963 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Hervé MENAGER +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..636f87dff3e97e93a36e876ee62d162a90dd66db --- /dev/null +++ b/README.rst @@ -0,0 +1,62 @@ +========================== +Django Basetheme Bootstrap +========================== + +A module handling basic fonctionnality needed in django project such as base.html using bootstrap, account settings, preferences management, ... + +Detailed documentation is in the "docs" directory. + +Quick start +----------- + +1. Add "basetheme_bootstrap" to your INSTALLED_APPS setting like this (while not forgetting `crispy_forms`) :: + + INSTALLED_APPS = [ + ... + 'crispy_forms', + 'basetheme_bootstrap', + ] + +2. Add the context_processors:: + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + ... + 'basetheme_bootstrap.context_processors.processors', + ], + }, + }, + ] + +3. Adjust the configuration:: + + + ################################################################################ + # django-crispy-forms + ################################################################################ + CRISPY_TEMPLATE_PACK = 'bootstrap4' + + ################################################################################ + # basetheme_bootstrap + ################################################################################ + BASETHEME_BOOTSTRAP_TEMPLATE_LOCATION_PROJECT = "test_app_1" + BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED = True + BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_LOCATION_APP = "test_app_1" + BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME = "MyUserPreferences" + + ################################################################################ + +3. Include the polls URLconf in your project urls.py like this:: + + path('', include('basetheme_bootstrap.urls')), + +3. Run `python manage.py migrate` to create the UserPreferences model if you decided to have one. + +4. Make your templates extends base template from`basetheme_bootstrap`, or even better create your own base.html which extends the one from `basetheme_bootstrap`:: + + {% extends "basetheme_bootstrap/base4.html" %} \ No newline at end of file diff --git a/basetheme_bootstrap/__init__.py b/basetheme_bootstrap/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basetheme_bootstrap/admin.py b/basetheme_bootstrap/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..56f646eff710821f6fa9dc4e4f48d200facaca33 --- /dev/null +++ b/basetheme_bootstrap/admin.py @@ -0,0 +1,70 @@ +from django.contrib import admin +from django.contrib.admin.options import get_content_type_for_model +from django.urls import reverse +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext + +from basetheme_bootstrap.user_preferences import get_user_preference_class + + +class ViewOnSiteModelAdmin(admin.ModelAdmin): + class Media: + css = { + 'all': ('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css',) + } + + def __init__(self, model, admin_site): + if callable(getattr(model, "get_absolute_url", None)) and callable(self.view_on_site_in_list): + self.list_display += ('view_on_site_in_list',) + super().__init__(model, admin_site) + + def view_on_site_in_list(self, obj): + return format_html( + '<center><a href="' + reverse('admin:view_on_site', kwargs={ + 'content_type_id': get_content_type_for_model(obj).pk, + 'object_id': obj.pk + }) + '"><i class="fa fa-external-link"></i></a><center>') + + view_on_site_in_list.short_description = format_html('<center>' + ugettext('View on site') + '<center>') + + +class UserPreferencesAdmin(admin.ModelAdmin): + ordering = ('user',) + list_display = ['username', ] + [ + field.name + for field in ( + [] + if get_user_preference_class() is None + else get_user_preference_class()._meta.get_fields() + ) + if field.name not in [ + "id", + "user", + ] + ] + list_filter = [ + field.name + for field in ( + [] + if get_user_preference_class() is None + else get_user_preference_class()._meta.get_fields() + ) + if field.name not in [ + "id", + "user", + ] + ] + + def username(self, obj): + return mark_safe("<i>default preferences</i>") if obj.user is None else obj.user + + username.admin_order_field = 'user__username' + + +try: + admin.site.register(get_user_preference_class(), UserPreferencesAdmin) +except admin.sites.AlreadyRegistered as e: + pass +except TypeError as e: + pass diff --git a/basetheme_bootstrap/apps.py b/basetheme_bootstrap/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..2ea0ce9b46caff7b1d515bf5639c0d4d98409167 --- /dev/null +++ b/basetheme_bootstrap/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BasethemeBootstrapConfig(AppConfig): + name = 'basetheme_bootstrap' diff --git a/basetheme_bootstrap/context_processors.py b/basetheme_bootstrap/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..ddfcd2eb0168dde3d595f62473975aa8d3325aff --- /dev/null +++ b/basetheme_bootstrap/context_processors.py @@ -0,0 +1,26 @@ +import logging + +from django.core.cache import cache + +from basetheme_bootstrap.templatetags.basetheme_bootstrap import basetheme_bootstrap_template_if_not_redefined +from basetheme_bootstrap.user_preferences import get_user_preferences_for_user + +logger = logging.getLogger("basetheme_bootstrap") + + +def processors(request): + return dict( + basetheme_bootstrap_base_template=get_basetheme_bootstrap_base_template(), + pref=get_user_preferences_for_user(None if request.user.is_anonymous else request.user), + ) + + +def get_basetheme_bootstrap_base_template(): + r = cache.get( + 'basetheme_bootstrap_base_template', + default=None, + ) + if r is None: + r = basetheme_bootstrap_template_if_not_redefined("base.html") + cache.set('basetheme_bootstrap_base_template', r, None) + return r diff --git a/basetheme_bootstrap/db_finder.py b/basetheme_bootstrap/db_finder.py new file mode 100644 index 0000000000000000000000000000000000000000..13ecf07dc081d734dc99676c02d88a7bfaff87a0 --- /dev/null +++ b/basetheme_bootstrap/db_finder.py @@ -0,0 +1,46 @@ +import os +import subprocess + + +def get_db_ip(): + result = subprocess.run([ + "docker", + "ps", + "-f", + "name=%s" % get_guessed_container_name(), + "-q", + ], stdout=subprocess.PIPE) + ids = result.stdout.decode('utf-8').strip().split('\n') + if len(ids) > 1: + raise Exception("Can't find the DB, too much match") + if len(ids) == 0 or len(ids[0]) == 0: + result = subprocess.run([ + "docker", + "ps", + "-f", + "name=_db", + "-q", + ], stdout=subprocess.PIPE) + ids = result.stdout.decode('utf-8').strip().split('\n') + if len(ids) > 1: + raise Exception( + "Can't find the DB, couldn't guess container name (tried '%s'), " + "and too much match with '_db'" % get_guessed_container_name()) + if len(ids) == 0 or len(ids[0]) == 0: + raise Exception("Can't find the DB") + result = subprocess.run([ + "docker", + "inspect", + "-f", + "'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'", + ids[0], + ], stdout=subprocess.PIPE) + return result.stdout.decode('utf-8').strip().replace("'", "") + + +def get_guessed_container_name(): + return str(os.path.dirname(__file__).split(os.path.sep)[-2]).lower().replace('-', '') + "_db" + + +if __name__ == "__main__": + print(get_db_ip()) diff --git a/basetheme_bootstrap/forms.py b/basetheme_bootstrap/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..e1f9b6243aee49eb58b6d11e88428730efe13f7e --- /dev/null +++ b/basetheme_bootstrap/forms.py @@ -0,0 +1,30 @@ +import itertools + +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import UserCreationForm, UsernameField, UserChangeForm +from django.db.models.query_utils import Q +from django.forms import ModelForm +from django.utils.translation import ugettext as _ + + +class UserCreationFormWithMore(UserCreationForm): + class Meta: + model = get_user_model() + fields = ("username", "email", "first_name", "last_name") + field_classes = {'username': UsernameField} + + def __init__(self, *args, **kwargs): + super(UserCreationFormWithMore, self).__init__(*args, **kwargs) + self.fields['email'].widget.attrs.update({'required': True}) + + +class MyUserChangeForm(UserChangeForm): + class Meta: + model = get_user_model() + fields = ("username", "email", "first_name", "last_name", "password") + + +class UserDeleteForm(ModelForm): + class Meta: + model = get_user_model() + fields = () \ No newline at end of file diff --git a/basetheme_bootstrap/migrations/__init__.py b/basetheme_bootstrap/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/basetheme_bootstrap/models.py b/basetheme_bootstrap/models.py new file mode 100644 index 0000000000000000000000000000000000000000..71a836239075aa6e6e4ecb700e9c42c95c022d91 --- /dev/null +++ b/basetheme_bootstrap/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/basetheme_bootstrap/static/css/basetheme_bootstrap.css b/basetheme_bootstrap/static/css/basetheme_bootstrap.css new file mode 100644 index 0000000000000000000000000000000000000000..6c3603f8f0c8fdf3f1016ba6c292d86b75d473d3 --- /dev/null +++ b/basetheme_bootstrap/static/css/basetheme_bootstrap.css @@ -0,0 +1,55 @@ +.fork_me{ + font-family: tahoma; + font-size: 20px; + position:fixed; + top:75px; + right:-55px; + display:block; + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + background-color:#c5b1e7; + color:black; + padding: 4px 60px 4px 60px; + z-index:99; +} +.dt-buttons{ + margin-bottom: 5px; +} +.dt-button{ + /*from .btn*/ + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: 400; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; + + /*from .btn-default*/ + text-shadow: 0 1px 0 #fff; + background-image: -webkit-linear-gradient(top,#fff 0,#e0e0e0 100%); + background-image: -o-linear-gradient(top,#fff 0,#e0e0e0 100%); + background-image: -webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0)); + background-image: linear-gradient(to bottom,#fff 0,#e0e0e0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); + background-repeat: repeat-x; + border-color: #dbdbdb; + border-color: #ccc; +} + +.pull-right + .pull-right{ +margin-right:4px; +} \ No newline at end of file diff --git a/basetheme_bootstrap/static/css/basetheme_bootstrap4.css b/basetheme_bootstrap/static/css/basetheme_bootstrap4.css new file mode 100644 index 0000000000000000000000000000000000000000..e235350ca2c23470020281e11d9017556fc242f9 --- /dev/null +++ b/basetheme_bootstrap/static/css/basetheme_bootstrap4.css @@ -0,0 +1,51 @@ +.fork_me{ + font-family: tahoma; + font-size: 20px; + position:fixed; + top:75px; + right:-55px; + display:block; + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + background-color:#c5b1e7; + color:black; + padding: 4px 60px 4px 60px; + z-index:99; +} +.bd-placeholder-img { + font-size: 1.125rem; + text-anchor: middle; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +@media (min-width: 768px) { + .bd-placeholder-img-lg { + font-size: 3.5rem; + } +} + +body { + padding-top: 3.5rem; +} + +.footer { + background-color: #f5f5f5; +} +@media (min-width: 1600px){ + .container { + max-width: 1530px; + } +} +@media (min-width: 1920px){ + .container { + max-width: 1830px; + } +} +@media (min-width: 2560px){ + .container { + max-width: 2490px; + } +} \ No newline at end of file diff --git a/basetheme_bootstrap/static/css/bootstrap-multiselect.css b/basetheme_bootstrap/static/css/bootstrap-multiselect.css new file mode 100644 index 0000000000000000000000000000000000000000..6a6b68a249811e054fa8d759452816a9248c0748 --- /dev/null +++ b/basetheme_bootstrap/static/css/bootstrap-multiselect.css @@ -0,0 +1 @@ +span.multiselect-native-select{position:relative}span.multiselect-native-select select{border:0!important;clip:rect(0 0 0 0)!important;height:1px!important;margin:-1px -1px -1px -3px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;left:50%;top:30px}.multiselect-container{position:absolute;list-style-type:none;margin:0;padding:0}.multiselect-container .input-group{margin:5px}.multiselect-container .multiselect-reset .input-group{width:93%}.multiselect-container>li{padding:0}.multiselect-container>li>a.multiselect-all label{font-weight:700}.multiselect-container>li.multiselect-group label{margin:0;padding:3px 20px;height:100%;font-weight:700}.multiselect-container>li.multiselect-group-clickable label{cursor:pointer}.multiselect-container>li>a{padding:0}.multiselect-container>li>a>label{margin:0;height:100%;cursor:pointer;font-weight:400;padding:3px 20px 3px 40px}.multiselect-container>li>a>label.checkbox,.multiselect-container>li>a>label.radio{margin:0}.multiselect-container>li>a>label>input[type=checkbox]{margin-bottom:5px}.btn-group>.btn-group:nth-child(2)>.multiselect.btn{border-top-left-radius:4px;border-bottom-left-radius:4px}.form-inline .multiselect-container label.checkbox,.form-inline .multiselect-container label.radio{padding:3px 20px 3px 40px}.form-inline .multiselect-container li a label.checkbox input[type=checkbox],.form-inline .multiselect-container li a label.radio input[type=radio]{margin-left:-20px;margin-right:0} \ No newline at end of file diff --git a/basetheme_bootstrap/static/css/bootstrap-xlgrid.css b/basetheme_bootstrap/static/css/bootstrap-xlgrid.css new file mode 100644 index 0000000000000000000000000000000000000000..c344f614e738afae078bc55d747a5cb91160f72f --- /dev/null +++ b/basetheme_bootstrap/static/css/bootstrap-xlgrid.css @@ -0,0 +1,624 @@ +/* +* Bootstrap 3 XL CSS Grid +* +* Bootstrap 3 XL CSS Grid addition for big and retina screens coverage +* +* Extends Bootstrap v3 by: +* Adding col-xl (1600+), col-xxl (1920+), col-xxxl (2560+) +* Adding visible and hidden options for all three +* Optionally limiting visible-lg, hidden-lg (1200+) classes to max 1600px +* Optionally increasing container fixed width for all three screen sizes +* +* Copyright 2014 Arnis Puskeiris (apbyte, arnico) +* Licensed under the MIT License +* http://opensource.org/licenses/MIT +*/ + +/* ========================================================================== + Fix visible-lg and hidden-lg for resolutions over 1600px + Remove if don't want to use + ========================================================================== */ +@media (min-width: 1600px) { + .visible-lg { + display: none !important; + } + .hidden-lg { + display: block !important; + } + table.hidden-lg { + display: table; + } + tr.hidden-lg { + display: table-row !important; + } + th.hidden-lg, + td.hidden-lg { + display: table-cell !important; + } +} + +/* ========================================================================== + Set containers fixed sizes for >1600px, >1920px, >2560px + Remove all if don't want to use big fixed sizes for all blocks + You still can use cols with .container-fluid blocks + ========================================================================== */ +@media (min-width: 1600px) { + .container { + width: 1530px; + } +} +@media (min-width: 1920px) { + .container { + width: 1830px; + } +} +@media (min-width: 2560px) { + .container { + width: 2490px; + } +} + +/* ========================================================================== + col-xl, col-xxl, col-xxxl setup. + Don't remove anything below this line + ========================================================================== */ + +.col-xl-1, .col-xxl-1, .col-xxxl-1, .col-xl-2, .col-xxl-2, .col-md-2, .col-xxxl-2, .col-xl-3, .col-xxl-3, .col-md-3, .col-xxxl-3, .col-xl-4, .col-xxl-4, .col-md-4, .col-xxxl-4, .col-xl-5, .col-xxl-5, .col-md-5, .col-xxxl-5, .col-xl-6, .col-xxl-6, .col-md-6, .col-xxxl-6, .col-xl-7, .col-xxl-7, .col-md-7, .col-xxxl-7, .col-xl-8, .col-xxl-8, .col-md-8, .col-xxxl-8, .col-xl-9, .col-xxl-9, .col-md-9, .col-xxxl-9, .col-xl-10, .col-xxl-100, .col-xxxl-10, .col-xl-11, .col-xxl-111, .col-xxxl-11, .col-xl-12, .col-xxl-122, .col-xxxl-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} +@media (min-width: 1600px) { + .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12 { + float: left; + } + .col-xl-12 { + width: 100%; + } + .col-xl-11 { + width: 91.66666667%; + } + .col-xl-10 { + width: 83.33333333%; + } + .col-xl-9 { + width: 75%; + } + .col-xl-8 { + width: 66.66666667%; + } + .col-xl-7 { + width: 58.33333333%; + } + .col-xl-6 { + width: 50%; + } + .col-xl-5 { + width: 41.66666667%; + } + .col-xl-4 { + width: 33.33333333%; + } + .col-xl-3 { + width: 25%; + } + .col-xl-2 { + width: 16.66666667%; + } + .col-xl-1 { + width: 8.33333333%; + } + .col-xl-pull-12 { + right: 100%; + } + .col-xl-pull-11 { + right: 91.66666667%; + } + .col-xl-pull-10 { + right: 83.33333333%; + } + .col-xl-pull-9 { + right: 75%; + } + .col-xl-pull-8 { + right: 66.66666667%; + } + .col-xl-pull-7 { + right: 58.33333333%; + } + .col-xl-pull-6 { + right: 50%; + } + .col-xl-pull-5 { + right: 41.66666667%; + } + .col-xl-pull-4 { + right: 33.33333333%; + } + .col-xl-pull-3 { + right: 25%; + } + .col-xl-pull-2 { + right: 16.66666667%; + } + .col-xl-pull-1 { + right: 8.33333333%; + } + .col-xl-pull-0 { + right: 0; + } + .col-xl-push-12 { + left: 100%; + } + .col-xl-push-11 { + left: 91.66666667%; + } + .col-xl-push-10 { + left: 83.33333333%; + } + .col-xl-push-9 { + left: 75%; + } + .col-xl-push-8 { + left: 66.66666667%; + } + .col-xl-push-7 { + left: 58.33333333%; + } + .col-xl-push-6 { + left: 50%; + } + .col-xl-push-5 { + left: 41.66666667%; + } + .col-xl-push-4 { + left: 33.33333333%; + } + .col-xl-push-3 { + left: 25%; + } + .col-xl-push-2 { + left: 16.66666667%; + } + .col-xl-push-1 { + left: 8.33333333%; + } + .col-xl-push-0 { + left: 0; + } + .col-xl-offset-12 { + margin-left: 100%; + } + .col-xl-offset-11 { + margin-left: 91.66666667%; + } + .col-xl-offset-10 { + margin-left: 83.33333333%; + } + .col-xl-offset-9 { + margin-left: 75%; + } + .col-xl-offset-8 { + margin-left: 66.66666667%; + } + .col-xl-offset-7 { + margin-left: 58.33333333%; + } + .col-xl-offset-6 { + margin-left: 50%; + } + .col-xl-offset-5 { + margin-left: 41.66666667%; + } + .col-xl-offset-4 { + margin-left: 33.33333333%; + } + .col-xl-offset-3 { + margin-left: 25%; + } + .col-xl-offset-2 { + margin-left: 16.66666667%; + } + .col-xl-offset-1 { + margin-left: 8.33333333%; + } + .col-xl-offset-0 { + margin-left: 0; + } +} + +@media (min-width: 1920px) { + .col-xxl-1, .col-xxl-2, .col-xxl-3, .col-xxl-4, .col-xxl-5, .col-xxl-6, .col-xxl-7, .col-xxl-8, .col-xxl-9, .col-xxl-10, .col-xxl-11, .col-xxl-12 { + float: left; + } + .col-xxl-12 { + width: 100%; + } + .col-xxl-11 { + width: 91.66666667%; + } + .col-xxl-10 { + width: 83.33333333%; + } + .col-xxl-9 { + width: 75%; + } + .col-xxl-8 { + width: 66.66666667%; + } + .col-xxl-7 { + width: 58.33333333%; + } + .col-xxl-6 { + width: 50%; + } + .col-xxl-5 { + width: 41.66666667%; + } + .col-xxl-4 { + width: 33.33333333%; + } + .col-xxl-3 { + width: 25%; + } + .col-xxl-2 { + width: 16.66666667%; + } + .col-xxl-1 { + width: 8.33333333%; + } + .col-xxl-pull-12 { + right: 100%; + } + .col-xxl-pull-11 { + right: 91.66666667%; + } + .col-xxl-pull-10 { + right: 83.33333333%; + } + .col-xxl-pull-9 { + right: 75%; + } + .col-xxl-pull-8 { + right: 66.66666667%; + } + .col-xxl-pull-7 { + right: 58.33333333%; + } + .col-xxl-pull-6 { + right: 50%; + } + .col-xxl-pull-5 { + right: 41.66666667%; + } + .col-xxl-pull-4 { + right: 33.33333333%; + } + .col-xxl-pull-3 { + right: 25%; + } + .col-xxl-pull-2 { + right: 16.66666667%; + } + .col-xxl-pull-1 { + right: 8.33333333%; + } + .col-xxl-pull-0 { + right: 0; + } + .col-xxl-push-12 { + left: 100%; + } + .col-xxl-push-11 { + left: 91.66666667%; + } + .col-xxl-push-10 { + left: 83.33333333%; + } + .col-xxl-push-9 { + left: 75%; + } + .col-xxl-push-8 { + left: 66.66666667%; + } + .col-xxl-push-7 { + left: 58.33333333%; + } + .col-xxl-push-6 { + left: 50%; + } + .col-xxl-push-5 { + left: 41.66666667%; + } + .col-xxl-push-4 { + left: 33.33333333%; + } + .col-xxl-push-3 { + left: 25%; + } + .col-xxl-push-2 { + left: 16.66666667%; + } + .col-xxl-push-1 { + left: 8.33333333%; + } + .col-xxl-push-0 { + left: 0; + } + .col-xxl-offset-12 { + margin-left: 100%; + } + .col-xxl-offset-11 { + margin-left: 91.66666667%; + } + .col-xxl-offset-10 { + margin-left: 83.33333333%; + } + .col-xxl-offset-9 { + margin-left: 75%; + } + .col-xxl-offset-8 { + margin-left: 66.66666667%; + } + .col-xxl-offset-7 { + margin-left: 58.33333333%; + } + .col-xxl-offset-6 { + margin-left: 50%; + } + .col-xxl-offset-5 { + margin-left: 41.66666667%; + } + .col-xxl-offset-4 { + margin-left: 33.33333333%; + } + .col-xxl-offset-3 { + margin-left: 25%; + } + .col-xxl-offset-2 { + margin-left: 16.66666667%; + } + .col-xxl-offset-1 { + margin-left: 8.33333333%; + } + .col-xxl-offset-0 { + margin-left: 0; + } +} + +@media (min-width: 2560px) { + .col-xxxl-1, .col-xxxl-2, .col-xxxl-3, .col-xxxl-4, .col-xxxl-5, .col-xxxl-6, .col-xxxl-7, .col-xxxl-8, .col-xxxl-9, .col-xxxl-10, .col-xxxl-11, .col-xxxl-12 { + float: left; + } + .col-xxxl-12 { + width: 100%; + } + .col-xxxl-11 { + width: 91.66666667%; + } + .col-xxxl-10 { + width: 83.33333333%; + } + .col-xxxl-9 { + width: 75%; + } + .col-xxxl-8 { + width: 66.66666667%; + } + .col-xxxl-7 { + width: 58.33333333%; + } + .col-xxxl-6 { + width: 50%; + } + .col-xxxl-5 { + width: 41.66666667%; + } + .col-xxxl-4 { + width: 33.33333333%; + } + .col-xxxl-3 { + width: 25%; + } + .col-xxxl-2 { + width: 16.66666667%; + } + .col-xxxl-1 { + width: 8.33333333%; + } + .col-xxxl-pull-12 { + right: 100%; + } + .col-xxxl-pull-11 { + right: 91.66666667%; + } + .col-xxxl-pull-10 { + right: 83.33333333%; + } + .col-xxxl-pull-9 { + right: 75%; + } + .col-xxxl-pull-8 { + right: 66.66666667%; + } + .col-xxxl-pull-7 { + right: 58.33333333%; + } + .col-xxxl-pull-6 { + right: 50%; + } + .col-xxxl-pull-5 { + right: 41.66666667%; + } + .col-xxxl-pull-4 { + right: 33.33333333%; + } + .col-xxxl-pull-3 { + right: 25%; + } + .col-xxxl-pull-2 { + right: 16.66666667%; + } + .col-xxxl-pull-1 { + right: 8.33333333%; + } + .col-xxxl-pull-0 { + right: 0; + } + .col-xxxl-push-12 { + left: 100%; + } + .col-xxxl-push-11 { + left: 91.66666667%; + } + .col-xxxl-push-10 { + left: 83.33333333%; + } + .col-xxxl-push-9 { + left: 75%; + } + .col-xxxl-push-8 { + left: 66.66666667%; + } + .col-xxxl-push-7 { + left: 58.33333333%; + } + .col-xxxl-push-6 { + left: 50%; + } + .col-xxxl-push-5 { + left: 41.66666667%; + } + .col-xxxl-push-4 { + left: 33.33333333%; + } + .col-xxxl-push-3 { + left: 25%; + } + .col-xxxl-push-2 { + left: 16.66666667%; + } + .col-xxxl-push-1 { + left: 8.33333333%; + } + .col-xxxl-push-0 { + left: 0; + } + .col-xxxl-offset-12 { + margin-left: 100%; + } + .col-xxxl-offset-11 { + margin-left: 91.66666667%; + } + .col-xxxl-offset-10 { + margin-left: 83.33333333%; + } + .col-xxxl-offset-9 { + margin-left: 75%; + } + .col-xxxl-offset-8 { + margin-left: 66.66666667%; + } + .col-xxxl-offset-7 { + margin-left: 58.33333333%; + } + .col-xxxl-offset-6 { + margin-left: 50%; + } + .col-xxxl-offset-5 { + margin-left: 41.66666667%; + } + .col-xxxl-offset-4 { + margin-left: 33.33333333%; + } + .col-xxxl-offset-3 { + margin-left: 25%; + } + .col-xxxl-offset-2 { + margin-left: 16.66666667%; + } + .col-xxxl-offset-1 { + margin-left: 8.33333333%; + } + .col-xxxl-offset-0 { + margin-left: 0; + } +} + + + + + +.visible-xl, +.visible-xxl, +.visible-xxxl { + display: none !important; +} + +@media (min-width: 1600px) and (max-width: 1919px) { + .visible-xl { + display: block !important; + } + table.visible-xl { + display: table; + } + tr.visible-xl { + display: table-row !important; + } + th.visible-xl, + td.visible-xl { + display: table-cell !important; + } +} +@media (min-width: 1920px) and (max-width: 2559px) { + .visible-xxl { + display: block !important; + } + table.visible-xxl { + display: table; + } + tr.visible-xxl { + display: table-row !important; + } + th.visible-xxl, + td.visible-xxl { + display: table-cell !important; + } +} +@media (min-width: 2560px) { + .visible-xxxl { + display: block !important; + } + table.visible-xxxl { + display: table; + } + tr.visible-xxxl { + display: table-row !important; + } + th.visible-xxxl, + td.visible-xxxl { + display: table-cell !important; + } +} + + + + +@media (min-width: 1600px) and (max-width: 1919px) { + .hidden-xl { + display: none !important; + } +} + +@media (min-width: 1920px) and (max-width: 2559px) { + .hidden-xxl { + display: none !important; + } +} + +@media (min-width: 2560px) { + .hidden-xxxl { + display: none !important; + } +} diff --git a/basetheme_bootstrap/static/js/basetheme_bootstrap.js b/basetheme_bootstrap/static/js/basetheme_bootstrap.js new file mode 100644 index 0000000000000000000000000000000000000000..b9797dc712c5b63c94aa9b6617be1cf6c646746a --- /dev/null +++ b/basetheme_bootstrap/static/js/basetheme_bootstrap.js @@ -0,0 +1,92 @@ +function stack_onload(f) { + var old = window.onload; + if (typeof window.onload != 'function') + window.onload = f; + else + window.onload = function() { + if (old) + old() + f(); + } +} +//https://www.w3schools.com/js/js_cookies.asp +function setCookie(cname, cvalue, exdays) { + var d = new Date(); + d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); + var expires = "expires="+d.toUTCString(); + document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; +} + +//https://www.w3schools.com/js/js_cookies.asp +function getCookie(cname, default_value) { + var name = cname + "="; + var ca = document.cookie.split(';'); + for(var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + if (typeof default_value != "undefined") + return default_value; + return ""; +} + +//https://www.w3schools.com/js/js_cookies.asp +function checkCookie() { + var user = getCookie("username"); + if (user != "") { + alert("Welcome again " + user); + } else { + user = prompt("Please enter your name:", ""); + if (user != "" && user != null) { + setCookie("username", user, 365); + } + } +} + +function fade_border_to_and_back(target,color, color_org){ + if (typeof color_org == "undefined") + color_org = $(target).css("border-color"); + $(target) + .animate({borderColor: color}, 200 ) + .animate({borderColor: color_org}, 900 ); +} + +function fade_background_to_and_back(target,color, color_org){ + if (typeof color_org == "undefined") + color_org = $(target).css("background-color"); + $(target) + .animate({backgroundColor: color}, 200 ) + .animate({backgroundColor: color_org}, 900 ); +} + +$.fn.attrThatBeginWith = function(begins){ + return [].slice.call(this.get(0).attributes).filter(function(attr) { + return attr && attr.name && attr.name.indexOf(begins) === 0 + }); +}; + +//https://stackoverflow.com/a/6217551/2144569 +function delayed_action(action, instant_action, timeout){ + timeout = (typeof timeout == "undefined" ? 1200:timeout); + var callcount = 0; + var action = action + var delayAction = function(action, time){ + var expectcallcount = callcount; + var delay = function(){ + if(callcount == expectcallcount){ + action(); + } + } + setTimeout(delay, time); + } + return function(eventtrigger){ + ++callcount; + instant_action(eventtrigger); + delayAction(action, timeout); + } +} diff --git a/basetheme_bootstrap/static/js/bootstrap-multiselect.js b/basetheme_bootstrap/static/js/bootstrap-multiselect.js new file mode 100644 index 0000000000000000000000000000000000000000..02b21d2d10c7b4f9c15138b4b0b4b4ae2f913664 --- /dev/null +++ b/basetheme_bootstrap/static/js/bootstrap-multiselect.js @@ -0,0 +1,1775 @@ +/** + * Bootstrap Multiselect (http://davidstutz.de/bootstrap-multiselect/) + * + * Apache License, Version 2.0: + * Copyright (c) 2012 - 2018 David Stutz + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a + * copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * BSD 3-Clause License: + * Copyright (c) 2012 - 2018 David Stutz + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of David Stutz nor the names of its contributors may be + * used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +(function (root, factory) { + // check to see if 'knockout' AMD module is specified if using requirejs + if (typeof define === 'function' && define.amd && + typeof require === 'function' && typeof require.specified === 'function' && require.specified('knockout')) { + + // AMD. Register as an anonymous module. + define(['jquery', 'knockout'], factory); + } else { + // Browser globals + factory(root.jQuery, root.ko); + } +})(this, function ($, ko) { + "use strict";// jshint ;_; + + if (typeof ko !== 'undefined' && ko.bindingHandlers && !ko.bindingHandlers.multiselect) { + ko.bindingHandlers.multiselect = { + after: ['options', 'value', 'selectedOptions', 'enable', 'disable'], + + init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { + var $element = $(element); + var config = ko.toJS(valueAccessor()); + + $element.multiselect(config); + + if (allBindings.has('options')) { + var options = allBindings.get('options'); + if (ko.isObservable(options)) { + ko.computed({ + read: function() { + options(); + setTimeout(function() { + var ms = $element.data('multiselect'); + if (ms) + ms.updateOriginalOptions();//Not sure how beneficial this is. + $element.multiselect('rebuild'); + }, 1); + }, + disposeWhenNodeIsRemoved: element + }); + } + } + + //value and selectedOptions are two-way, so these will be triggered even by our own actions. + //It needs some way to tell if they are triggered because of us or because of outside change. + //It doesn't loop but it's a waste of processing. + if (allBindings.has('value')) { + var value = allBindings.get('value'); + if (ko.isObservable(value)) { + ko.computed({ + read: function() { + value(); + setTimeout(function() { + $element.multiselect('refresh'); + }, 1); + }, + disposeWhenNodeIsRemoved: element + }).extend({ rateLimit: 100, notifyWhenChangesStop: true }); + } + } + + //Switched from arrayChange subscription to general subscription using 'refresh'. + //Not sure performance is any better using 'select' and 'deselect'. + if (allBindings.has('selectedOptions')) { + var selectedOptions = allBindings.get('selectedOptions'); + if (ko.isObservable(selectedOptions)) { + ko.computed({ + read: function() { + selectedOptions(); + setTimeout(function() { + $element.multiselect('refresh'); + }, 1); + }, + disposeWhenNodeIsRemoved: element + }).extend({ rateLimit: 100, notifyWhenChangesStop: true }); + } + } + + var setEnabled = function (enable) { + setTimeout(function () { + if (enable) + $element.multiselect('enable'); + else + $element.multiselect('disable'); + }); + }; + + if (allBindings.has('enable')) { + var enable = allBindings.get('enable'); + if (ko.isObservable(enable)) { + ko.computed({ + read: function () { + setEnabled(enable()); + }, + disposeWhenNodeIsRemoved: element + }).extend({ rateLimit: 100, notifyWhenChangesStop: true }); + } else { + setEnabled(enable); + } + } + + if (allBindings.has('disable')) { + var disable = allBindings.get('disable'); + if (ko.isObservable(disable)) { + ko.computed({ + read: function () { + setEnabled(!disable()); + }, + disposeWhenNodeIsRemoved: element + }).extend({ rateLimit: 100, notifyWhenChangesStop: true }); + } else { + setEnabled(!disable); + } + } + + ko.utils.domNodeDisposal.addDisposeCallback(element, function() { + $element.multiselect('destroy'); + }); + }, + + update: function(element, valueAccessor, allBindings, viewModel, bindingContext) { + var $element = $(element); + var config = ko.toJS(valueAccessor()); + + $element.multiselect('setOptions', config); + $element.multiselect('rebuild'); + } + }; + } + + function forEach(array, callback) { + for (var index = 0; index < array.length; ++index) { + callback(array[index], index); + } + } + + /** + * Constructor to create a new multiselect using the given select. + * + * @param {jQuery} select + * @param {Object} options + * @returns {Multiselect} + */ + function Multiselect(select, options) { + + this.$select = $(select); + this.options = this.mergeOptions($.extend({}, options, this.$select.data())); + + // Placeholder via data attributes + if (this.$select.attr("data-placeholder")) { + this.options.nonSelectedText = this.$select.data("placeholder"); + } + + // Initialization. + // We have to clone to create a new reference. + this.originalOptions = this.$select.clone()[0].options; + this.query = ''; + this.searchTimeout = null; + this.lastToggledInput = null; + + this.options.multiple = this.$select.attr('multiple') === "multiple"; + this.options.onChange = $.proxy(this.options.onChange, this); + this.options.onSelectAll = $.proxy(this.options.onSelectAll, this); + this.options.onDeselectAll = $.proxy(this.options.onDeselectAll, this); + this.options.onDropdownShow = $.proxy(this.options.onDropdownShow, this); + this.options.onDropdownHide = $.proxy(this.options.onDropdownHide, this); + this.options.onDropdownShown = $.proxy(this.options.onDropdownShown, this); + this.options.onDropdownHidden = $.proxy(this.options.onDropdownHidden, this); + this.options.onInitialized = $.proxy(this.options.onInitialized, this); + this.options.onFiltering = $.proxy(this.options.onFiltering, this); + + // Build select all if enabled. + this.buildContainer(); + this.buildButton(); + this.buildDropdown(); + this.buildReset(); + this.buildSelectAll(); + this.buildDropdownOptions(); + this.buildFilter(); + + this.updateButtonText(); + this.updateSelectAll(true); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + this.options.wasDisabled = this.$select.prop('disabled'); + if (this.options.disableIfEmpty && $('option', this.$select).length <= 0) { + this.disable(); + } + + this.$select.wrap('<span class="multiselect-native-select" />').after(this.$container); + this.options.onInitialized(this.$select, this.$container); + } + + Multiselect.prototype = { + + defaults: { + /** + * Default text function will either print 'None selected' in case no + * option is selected or a list of the selected options up to a length + * of 3 selected options. + * + * @param {jQuery} options + * @param {jQuery} select + * @returns {String} + */ + buttonText: function(options, select) { + if (this.disabledText.length > 0 + && (select.prop('disabled') || (options.length == 0 && this.disableIfEmpty))) { + + return this.disabledText; + } + else if (options.length === 0) { + return this.nonSelectedText; + } + else if (this.allSelectedText + && options.length === $('option', $(select)).length + && $('option', $(select)).length !== 1 + && this.multiple) { + + if (this.selectAllNumber) { + return this.allSelectedText + ' (' + options.length + ')'; + } + else { + return this.allSelectedText; + } + } + else if (this.numberDisplayed != 0 && options.length > this.numberDisplayed) { + return options.length + ' ' + this.nSelectedText; + } + else { + var selected = ''; + var delimiter = this.delimiterText; + + options.each(function() { + var label = ($(this).attr('label') !== undefined) ? $(this).attr('label') : $(this).text(); + selected += label + delimiter; + }); + + return selected.substr(0, selected.length - this.delimiterText.length); + } + }, + /** + * Updates the title of the button similar to the buttonText function. + * + * @param {jQuery} options + * @param {jQuery} select + * @returns {@exp;selected@call;substr} + */ + buttonTitle: function(options, select) { + if (options.length === 0) { + return this.nonSelectedText; + } + else { + var selected = ''; + var delimiter = this.delimiterText; + + options.each(function () { + var label = ($(this).attr('label') !== undefined) ? $(this).attr('label') : $(this).text(); + selected += label + delimiter; + }); + return selected.substr(0, selected.length - this.delimiterText.length); + } + }, + checkboxName: function(option) { + return false; // no checkbox name + }, + /** + * Create a label. + * + * @param {jQuery} element + * @returns {String} + */ + optionLabel: function(element){ + return $(element).attr('label') || $(element).text(); + }, + /** + * Create a class. + * + * @param {jQuery} element + * @returns {String} + */ + optionClass: function(element) { + return $(element).attr('class') || ''; + }, + /** + * Triggered on change of the multiselect. + * + * Not triggered when selecting/deselecting options manually. + * + * @param {jQuery} option + * @param {Boolean} checked + */ + onChange : function(option, checked) { + + }, + /** + * Triggered when the dropdown is shown. + * + * @param {jQuery} event + */ + onDropdownShow: function(event) { + + }, + /** + * Triggered when the dropdown is hidden. + * + * @param {jQuery} event + */ + onDropdownHide: function(event) { + + }, + /** + * Triggered after the dropdown is shown. + * + * @param {jQuery} event + */ + onDropdownShown: function(event) { + + }, + /** + * Triggered after the dropdown is hidden. + * + * @param {jQuery} event + */ + onDropdownHidden: function(event) { + + }, + /** + * Triggered on select all. + */ + onSelectAll: function() { + + }, + /** + * Triggered on deselect all. + */ + onDeselectAll: function() { + + }, + /** + * Triggered after initializing. + * + * @param {jQuery} $select + * @param {jQuery} $container + */ + onInitialized: function($select, $container) { + + }, + /** + * Triggered on filtering. + * + * @param {jQuery} $filter + */ + onFiltering: function($filter) { + + }, + enableHTML: false, + buttonClass: 'btn btn-default', + inheritClass: false, + buttonWidth: 'auto', + buttonContainer: '<div class="btn-group" />', + dropRight: false, + dropUp: false, + selectedClass: 'active', + // Maximum height of the dropdown menu. + // If maximum height is exceeded a scrollbar will be displayed. + maxHeight: false, + includeSelectAllOption: false, + includeSelectAllIfMoreThan: 0, + selectAllText: ' Select all', + selectAllValue: 'multiselect-all', + selectAllName: false, + selectAllNumber: true, + selectAllJustVisible: true, + enableFiltering: false, + enableCaseInsensitiveFiltering: false, + enableFullValueFiltering: false, + enableClickableOptGroups: false, + enableCollapsibleOptGroups: false, + collapseOptGroupsByDefault: false, + filterPlaceholder: 'Search', + // possible options: 'text', 'value', 'both' + filterBehavior: 'text', + includeFilterClearBtn: true, + preventInputChangeEvent: false, + nonSelectedText: 'None selected', + nSelectedText: 'selected', + allSelectedText: 'All selected', + numberDisplayed: 3, + disableIfEmpty: false, + disabledText: '', + delimiterText: ', ', + includeResetOption: false, + includeResetDivider: false, + resetText: 'Reset', + templates: { + button: '<button type="button" class="multiselect dropdown-toggle" data-toggle="dropdown"><span class="multiselect-selected-text"></span> <b class="caret"></b></button>', + ul: '<ul class="multiselect-container dropdown-menu"></ul>', + filter: '<li class="multiselect-item multiselect-filter"><div class="input-group"><span class="input-group-addon"><i class="glyphicon glyphicon-search"></i></span><input class="form-control multiselect-search" type="text" /></div></li>', + filterClearBtn: '<span class="input-group-btn"><button class="btn btn-default multiselect-clear-filter" type="button"><i class="glyphicon glyphicon-remove-circle"></i></button></span>', + li: '<li><a tabindex="0"><label></label></a></li>', + divider: '<li class="multiselect-item divider"></li>', + liGroup: '<li class="multiselect-item multiselect-group"><label></label></li>', + resetButton: '<li class="multiselect-reset text-center"><div class="input-group"><a class="btn btn-default btn-block"></a></div></li>' + } + }, + + constructor: Multiselect, + + /** + * Builds the container of the multiselect. + */ + buildContainer: function() { + this.$container = $(this.options.buttonContainer); + this.$container.on('show.bs.dropdown', this.options.onDropdownShow); + this.$container.on('hide.bs.dropdown', this.options.onDropdownHide); + this.$container.on('shown.bs.dropdown', this.options.onDropdownShown); + this.$container.on('hidden.bs.dropdown', this.options.onDropdownHidden); + }, + + /** + * Builds the button of the multiselect. + */ + buildButton: function() { + this.$button = $(this.options.templates.button).addClass(this.options.buttonClass); + if (this.$select.attr('class') && this.options.inheritClass) { + this.$button.addClass(this.$select.attr('class')); + } + // Adopt active state. + if (this.$select.prop('disabled')) { + this.disable(); + } + else { + this.enable(); + } + + // Manually add button width if set. + if (this.options.buttonWidth && this.options.buttonWidth !== 'auto') { + this.$button.css({ + 'width' : '100%', //this.options.buttonWidth, + 'overflow' : 'hidden', + 'text-overflow' : 'ellipsis' + }); + this.$container.css({ + 'width': this.options.buttonWidth + }); + } + + // Keep the tab index from the select. + var tabindex = this.$select.attr('tabindex'); + if (tabindex) { + this.$button.attr('tabindex', tabindex); + } + + this.$container.prepend(this.$button); + }, + + /** + * Builds the ul representing the dropdown menu. + */ + buildDropdown: function() { + + // Build ul. + this.$ul = $(this.options.templates.ul); + + if (this.options.dropRight) { + this.$ul.addClass('pull-right'); + } + + // Set max height of dropdown menu to activate auto scrollbar. + if (this.options.maxHeight) { + this.$ul.css({ + 'max-height': this.options.maxHeight + 'px', + 'overflow-y': 'auto', + 'overflow-x': 'hidden' + }); + } + + if (this.options.dropUp) { + + var height = Math.min(this.options.maxHeight, $('option[data-role!="divider"]', this.$select).length*26 + $('option[data-role="divider"]', this.$select).length*19 + (this.options.includeSelectAllOption ? 26 : 0) + (this.options.enableFiltering || this.options.enableCaseInsensitiveFiltering ? 44 : 0)); + var moveCalc = height + 34; + + this.$ul.css({ + 'max-height': height + 'px', + 'overflow-y': 'auto', + 'overflow-x': 'hidden', + 'margin-top': "-" + moveCalc + 'px' + }); + } + + this.$container.append(this.$ul); + }, + + /** + * Build the dropdown options and binds all necessary events. + * + * Uses createDivider and createOptionValue to create the necessary options. + */ + buildDropdownOptions: function() { + + this.$select.children().each($.proxy(function(index, element) { + + var $element = $(element); + // Support optgroups and options without a group simultaneously. + var tag = $element.prop('tagName') + .toLowerCase(); + + if ($element.prop('value') === this.options.selectAllValue) { + return; + } + + if (tag === 'optgroup') { + this.createOptgroup(element); + } + else if (tag === 'option') { + + if ($element.data('role') === 'divider') { + this.createDivider(); + } + else { + this.createOptionValue(element); + } + + } + + // Other illegal tags will be ignored. + }, this)); + + // Bind the change event on the dropdown elements. + $(this.$ul).off('change', 'li:not(.multiselect-group) input[type="checkbox"], li:not(.multiselect-group) input[type="radio"]'); + $(this.$ul).on('change', 'li:not(.multiselect-group) input[type="checkbox"], li:not(.multiselect-group) input[type="radio"]', $.proxy(function(event) { + var $target = $(event.target); + + var checked = $target.prop('checked') || false; + var isSelectAllOption = $target.val() === this.options.selectAllValue; + + // Apply or unapply the configured selected class. + if (this.options.selectedClass) { + if (checked) { + $target.closest('li') + .addClass(this.options.selectedClass); + } + else { + $target.closest('li') + .removeClass(this.options.selectedClass); + } + } + + // Get the corresponding option. + var value = $target.val(); + var $option = this.getOptionByValue(value); + + var $optionsNotThis = $('option', this.$select).not($option); + var $checkboxesNotThis = $('input', this.$container).not($target); + + if (isSelectAllOption) { + + if (checked) { + this.selectAll(this.options.selectAllJustVisible, true); + } + else { + this.deselectAll(this.options.selectAllJustVisible, true); + } + } + else { + if (checked) { + $option.prop('selected', true); + + if (this.options.multiple) { + // Simply select additional option. + $option.prop('selected', true); + } + else { + // Unselect all other options and corresponding checkboxes. + if (this.options.selectedClass) { + $($checkboxesNotThis).closest('li').removeClass(this.options.selectedClass); + } + + $($checkboxesNotThis).prop('checked', false); + $optionsNotThis.prop('selected', false); + + // It's a single selection, so close. + this.$button.click(); + } + + if (this.options.selectedClass === "active") { + $optionsNotThis.closest("a").css("outline", ""); + } + } + else { + // Unselect option. + $option.prop('selected', false); + } + + // To prevent select all from firing onChange: #575 + this.options.onChange($option, checked); + + // Do not update select all or optgroups on select all change! + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + } + + this.$select.change(); + this.updateButtonText(); + + if(this.options.preventInputChangeEvent) { + return false; + } + }, this)); + + $('li a', this.$ul).on('mousedown', function(e) { + if (e.shiftKey) { + // Prevent selecting text by Shift+click + return false; + } + }); + + $(this.$ul).on('touchstart click', 'li a', $.proxy(function(event) { + event.stopPropagation(); + + var $target = $(event.target); + + if (event.shiftKey && this.options.multiple) { + if($target.is("label")){ // Handles checkbox selection manually (see https://github.com/davidstutz/bootstrap-multiselect/issues/431) + event.preventDefault(); + $target = $target.find("input"); + $target.prop("checked", !$target.prop("checked")); + } + var checked = $target.prop('checked') || false; + + if (this.lastToggledInput !== null && this.lastToggledInput !== $target) { // Make sure we actually have a range + var from = this.$ul.find("li:visible").index($target.parents("li")); + var to = this.$ul.find("li:visible").index(this.lastToggledInput.parents("li")); + + if (from > to) { // Swap the indices + var tmp = to; + to = from; + from = tmp; + } + + // Make sure we grab all elements since slice excludes the last index + ++to; + + // Change the checkboxes and underlying options + var range = this.$ul.find("li").not(".multiselect-filter-hidden").slice(from, to).find("input"); + + range.prop('checked', checked); + + if (this.options.selectedClass) { + range.closest('li') + .toggleClass(this.options.selectedClass, checked); + } + + for (var i = 0, j = range.length; i < j; i++) { + var $checkbox = $(range[i]); + + var $option = this.getOptionByValue($checkbox.val()); + + $option.prop('selected', checked); + } + } + + // Trigger the select "change" event + $target.trigger("change"); + } + + // Remembers last clicked option + if($target.is("input") && !$target.closest("li").is(".multiselect-item")){ + this.lastToggledInput = $target; + } + + $target.blur(); + }, this)); + + // Keyboard support. + this.$container.off('keydown.multiselect').on('keydown.multiselect', $.proxy(function(event) { + if ($('input[type="text"]', this.$container).is(':focus')) { + return; + } + + if (event.keyCode === 9 && this.$container.hasClass('open')) { + this.$button.click(); + } + else { + var $items = $(this.$container).find("li:not(.divider):not(.disabled) a").filter(":visible"); + + if (!$items.length) { + return; + } + + var index = $items.index($items.filter(':focus')); + + // Navigation up. + if (event.keyCode === 38 && index > 0) { + index--; + } + // Navigate down. + else if (event.keyCode === 40 && index < $items.length - 1) { + index++; + } + else if (!~index) { + index = 0; + } + + var $current = $items.eq(index); + $current.focus(); + + if (event.keyCode === 32 || event.keyCode === 13) { + var $checkbox = $current.find('input'); + + $checkbox.prop("checked", !$checkbox.prop("checked")); + $checkbox.change(); + } + + event.stopPropagation(); + event.preventDefault(); + } + }, this)); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + $("li.multiselect-group input", this.$ul).on("change", $.proxy(function(event) { + event.stopPropagation(); + + var $target = $(event.target); + var checked = $target.prop('checked') || false; + + var $li = $(event.target).closest('li'); + var $group = $li.nextUntil("li.multiselect-group") + .not('.multiselect-filter-hidden') + .not('.disabled'); + + var $inputs = $group.find("input"); + + var values = []; + var $options = []; + + if (this.options.selectedClass) { + if (checked) { + $li.addClass(this.options.selectedClass); + } + else { + $li.removeClass(this.options.selectedClass); + } + } + + $.each($inputs, $.proxy(function(index, input) { + var value = $(input).val(); + var $option = this.getOptionByValue(value); + + if (checked) { + $(input).prop('checked', true); + $(input).closest('li') + .addClass(this.options.selectedClass); + + $option.prop('selected', true); + } + else { + $(input).prop('checked', false); + $(input).closest('li') + .removeClass(this.options.selectedClass); + + $option.prop('selected', false); + } + + $options.push(this.getOptionByValue(value)); + }, this)) + + // Cannot use select or deselect here because it would call updateOptGroups again. + + this.options.onChange($options, checked); + + this.$select.change(); + this.updateButtonText(); + this.updateSelectAll(); + }, this)); + } + + if (this.options.enableCollapsibleOptGroups && this.options.multiple) { + $("li.multiselect-group .caret-container", this.$ul).on("click", $.proxy(function(event) { + var $li = $(event.target).closest('li'); + var $inputs = $li.nextUntil("li.multiselect-group") + .not('.multiselect-filter-hidden'); + + var visible = true; + $inputs.each(function() { + visible = visible && !$(this).hasClass('multiselect-collapsible-hidden'); + }); + + if (visible) { + $inputs.hide() + .addClass('multiselect-collapsible-hidden'); + } + else { + $inputs.show() + .removeClass('multiselect-collapsible-hidden'); + } + }, this)); + + $("li.multiselect-all", this.$ul).css('background', '#f3f3f3').css('border-bottom', '1px solid #eaeaea'); + $("li.multiselect-all > a > label.checkbox", this.$ul).css('padding', '3px 20px 3px 35px'); + $("li.multiselect-group > a > input", this.$ul).css('margin', '4px 0px 5px -20px'); + } + }, + + /** + * Create an option using the given select option. + * + * @param {jQuery} element + */ + createOptionValue: function(element) { + var $element = $(element); + if ($element.is(':selected')) { + $element.prop('selected', true); + } + + // Support the label attribute on options. + var label = this.options.optionLabel(element); + var classes = this.options.optionClass(element); + var value = $element.val(); + var inputType = this.options.multiple ? "checkbox" : "radio"; + + var $li = $(this.options.templates.li); + var $label = $('label', $li); + $label.addClass(inputType); + $label.attr("title", label); + $li.addClass(classes); + + // Hide all children items when collapseOptGroupsByDefault is true + if (this.options.collapseOptGroupsByDefault && $(element).parent().prop("tagName").toLowerCase() === "optgroup") { + $li.addClass("multiselect-collapsible-hidden"); + $li.hide(); + } + + if (this.options.enableHTML) { + $label.html(" " + label); + } + else { + $label.text(" " + label); + } + + var $checkbox = $('<input/>').attr('type', inputType); + + var name = this.options.checkboxName($element); + if (name) { + $checkbox.attr('name', name); + } + + $label.prepend($checkbox); + + var selected = $element.prop('selected') || false; + $checkbox.val(value); + + if (value === this.options.selectAllValue) { + $li.addClass("multiselect-item multiselect-all"); + $checkbox.parent().parent() + .addClass('multiselect-all'); + } + + $label.attr('title', $element.attr('title')); + + this.$ul.append($li); + + if ($element.is(':disabled')) { + $checkbox.attr('disabled', 'disabled') + .prop('disabled', true) + .closest('a') + .attr("tabindex", "-1") + .closest('li') + .addClass('disabled'); + } + + $checkbox.prop('checked', selected); + + if (selected && this.options.selectedClass) { + $checkbox.closest('li') + .addClass(this.options.selectedClass); + } + }, + + /** + * Creates a divider using the given select option. + * + * @param {jQuery} element + */ + createDivider: function(element) { + var $divider = $(this.options.templates.divider); + this.$ul.append($divider); + }, + + /** + * Creates an optgroup. + * + * @param {jQuery} group + */ + createOptgroup: function(group) { + var label = $(group).attr("label"); + var value = $(group).attr("value"); + var $li = $('<li class="multiselect-item multiselect-group"><a href="javascript:void(0);"><label><b></b></label></a></li>'); + + var classes = this.options.optionClass(group); + $li.addClass(classes); + + if (this.options.enableHTML) { + $('label b', $li).html(" " + label); + } + else { + $('label b', $li).text(" " + label); + } + + if (this.options.enableCollapsibleOptGroups && this.options.multiple) { + $('a', $li).append('<span class="caret-container"><b class="caret"></b></span>'); + } + + if (this.options.enableClickableOptGroups && this.options.multiple) { + $('a label', $li).prepend('<input type="checkbox" value="' + value + '"/>'); + } + + if ($(group).is(':disabled')) { + $li.addClass('disabled'); + } + + this.$ul.append($li); + + $("option", group).each($.proxy(function($, group) { + this.createOptionValue(group); + }, this)) + }, + + /** + * Build the reset. + * + */ + buildReset: function() { + if (this.options.includeResetOption) { + + // Check whether to add a divider after the reset. + if (this.options.includeResetDivider) { + this.$ul.prepend($(this.options.templates.divider)); + } + + var $resetButton = $(this.options.templates.resetButton); + + if (this.options.enableHTML) { + $('a', $resetButton).html(this.options.resetText); + } + else { + $('a', $resetButton).text(this.options.resetText); + } + + $('a', $resetButton).click($.proxy(function(){ + this.clearSelection(); + }, this)); + + this.$ul.prepend($resetButton); + } + }, + + /** + * Build the select all. + * + * Checks if a select all has already been created. + */ + buildSelectAll: function() { + if (typeof this.options.selectAllValue === 'number') { + this.options.selectAllValue = this.options.selectAllValue.toString(); + } + + var alreadyHasSelectAll = this.hasSelectAll(); + + if (!alreadyHasSelectAll && this.options.includeSelectAllOption && this.options.multiple + && $('option', this.$select).length > this.options.includeSelectAllIfMoreThan) { + + // Check whether to add a divider after the select all. + if (this.options.includeSelectAllDivider) { + this.$ul.prepend($(this.options.templates.divider)); + } + + var $li = $(this.options.templates.li); + $('label', $li).addClass("checkbox"); + + if (this.options.enableHTML) { + $('label', $li).html(" " + this.options.selectAllText); + } + else { + $('label', $li).text(" " + this.options.selectAllText); + } + + if (this.options.selectAllName) { + $('label', $li).prepend('<input type="checkbox" name="' + this.options.selectAllName + '" />'); + } + else { + $('label', $li).prepend('<input type="checkbox" />'); + } + + var $checkbox = $('input', $li); + $checkbox.val(this.options.selectAllValue); + + $li.addClass("multiselect-item multiselect-all"); + $checkbox.parent().parent() + .addClass('multiselect-all'); + + this.$ul.prepend($li); + + $checkbox.prop('checked', false); + } + }, + + /** + * Builds the filter. + */ + buildFilter: function() { + + // Build filter if filtering OR case insensitive filtering is enabled and the number of options exceeds (or equals) enableFilterLength. + if (this.options.enableFiltering || this.options.enableCaseInsensitiveFiltering) { + var enableFilterLength = Math.max(this.options.enableFiltering, this.options.enableCaseInsensitiveFiltering); + + if (this.$select.find('option').length >= enableFilterLength) { + + this.$filter = $(this.options.templates.filter); + $('input', this.$filter).attr('placeholder', this.options.filterPlaceholder); + + // Adds optional filter clear button + if(this.options.includeFilterClearBtn) { + var clearBtn = $(this.options.templates.filterClearBtn); + clearBtn.on('click', $.proxy(function(event){ + clearTimeout(this.searchTimeout); + + this.query = ''; + this.$filter.find('.multiselect-search').val(''); + $('li', this.$ul).show().removeClass('multiselect-filter-hidden'); + + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + }, this)); + this.$filter.find('.input-group').append(clearBtn); + } + + this.$ul.prepend(this.$filter); + + this.$filter.val(this.query).on('click', function(event) { + event.stopPropagation(); + }).on('input keydown', $.proxy(function(event) { + // Cancel enter key default behaviour + if (event.which === 13) { + event.preventDefault(); + } + + // This is useful to catch "keydown" events after the browser has updated the control. + clearTimeout(this.searchTimeout); + + this.searchTimeout = this.asyncFunction($.proxy(function() { + + if (this.query !== event.target.value) { + this.query = event.target.value; + + var currentGroup, currentGroupVisible; + $.each($('li', this.$ul), $.proxy(function(index, element) { + var value = $('input', element).length > 0 ? $('input', element).val() : ""; + var text = $('label', element).text(); + + var filterCandidate = ''; + if ((this.options.filterBehavior === 'text')) { + filterCandidate = text; + } + else if ((this.options.filterBehavior === 'value')) { + filterCandidate = value; + } + else if (this.options.filterBehavior === 'both') { + filterCandidate = text + '\n' + value; + } + + if (value !== this.options.selectAllValue && text) { + + // By default lets assume that element is not + // interesting for this search. + var showElement = false; + + if (this.options.enableCaseInsensitiveFiltering) { + filterCandidate = filterCandidate.toLowerCase(); + this.query = this.query.toLowerCase(); + } + + if (this.options.enableFullValueFiltering && this.options.filterBehavior !== 'both') { + var valueToMatch = filterCandidate.trim().substring(0, this.query.length); + if (this.query.indexOf(valueToMatch) > -1) { + showElement = true; + } + } + else if (filterCandidate.indexOf(this.query) > -1) { + showElement = true; + } + + // Toggle current element (group or group item) according to showElement boolean. + if(!showElement){ + $(element).css('display', 'none'); + $(element).addClass('multiselect-filter-hidden'); + } + if(showElement){ + $(element).css('display', 'block'); + $(element).removeClass('multiselect-filter-hidden'); + } + + // Differentiate groups and group items. + if ($(element).hasClass('multiselect-group')) { + // Remember group status. + currentGroup = element; + currentGroupVisible = showElement; + } + else { + // Show group name when at least one of its items is visible. + if (showElement) { + $(currentGroup).show() + .removeClass('multiselect-filter-hidden'); + } + + // Show all group items when group name satisfies filter. + if (!showElement && currentGroupVisible) { + $(element).show() + .removeClass('multiselect-filter-hidden'); + } + } + } + }, this)); + } + + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + this.options.onFiltering(event.target); + + }, this), 300, this); + }, this)); + } + } + }, + + /** + * Unbinds the whole plugin. + */ + destroy: function() { + this.$container.remove(); + this.$select.show(); + + // reset original state + this.$select.prop('disabled', this.options.wasDisabled); + + this.$select.data('multiselect', null); + }, + + /** + * Refreshs the multiselect based on the selected options of the select. + */ + refresh: function () { + var inputs = {}; + $('li input', this.$ul).each(function() { + inputs[$(this).val()] = $(this); + }); + + $('option', this.$select).each($.proxy(function (index, element) { + var $elem = $(element); + var $input = inputs[$(element).val()]; + + if ($elem.is(':selected')) { + $input.prop('checked', true); + + if (this.options.selectedClass) { + $input.closest('li') + .addClass(this.options.selectedClass); + } + } + else { + $input.prop('checked', false); + + if (this.options.selectedClass) { + $input.closest('li') + .removeClass(this.options.selectedClass); + } + } + + if ($elem.is(":disabled")) { + $input.attr('disabled', 'disabled') + .prop('disabled', true) + .closest('li') + .addClass('disabled'); + } + else { + $input.prop('disabled', false) + .closest('li') + .removeClass('disabled'); + } + }, this)); + + this.updateButtonText(); + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + }, + + /** + * Select all options of the given values. + * + * If triggerOnChange is set to true, the on change event is triggered if + * and only if one value is passed. + * + * @param {Array} selectValues + * @param {Boolean} triggerOnChange + */ + select: function(selectValues, triggerOnChange) { + if(!$.isArray(selectValues)) { + selectValues = [selectValues]; + } + + for (var i = 0; i < selectValues.length; i++) { + var value = selectValues[i]; + + if (value === null || value === undefined) { + continue; + } + + var $option = this.getOptionByValue(value); + var $checkbox = this.getInputByValue(value); + + if($option === undefined || $checkbox === undefined) { + continue; + } + + if (!this.options.multiple) { + this.deselectAll(false); + } + + if (this.options.selectedClass) { + $checkbox.closest('li') + .addClass(this.options.selectedClass); + } + + $checkbox.prop('checked', true); + $option.prop('selected', true); + + if (triggerOnChange) { + this.options.onChange($option, true); + } + } + + this.updateButtonText(); + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + }, + + /** + * Clears all selected items. + */ + clearSelection: function () { + this.deselectAll(false); + this.updateButtonText(); + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + }, + + /** + * Deselects all options of the given values. + * + * If triggerOnChange is set to true, the on change event is triggered, if + * and only if one value is passed. + * + * @param {Array} deselectValues + * @param {Boolean} triggerOnChange + */ + deselect: function(deselectValues, triggerOnChange) { + if(!$.isArray(deselectValues)) { + deselectValues = [deselectValues]; + } + + for (var i = 0; i < deselectValues.length; i++) { + var value = deselectValues[i]; + + if (value === null || value === undefined) { + continue; + } + + var $option = this.getOptionByValue(value); + var $checkbox = this.getInputByValue(value); + + if($option === undefined || $checkbox === undefined) { + continue; + } + + if (this.options.selectedClass) { + $checkbox.closest('li') + .removeClass(this.options.selectedClass); + } + + $checkbox.prop('checked', false); + $option.prop('selected', false); + + if (triggerOnChange) { + this.options.onChange($option, false); + } + } + + this.updateButtonText(); + this.updateSelectAll(); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + }, + + /** + * Selects all enabled & visible options. + * + * If justVisible is true or not specified, only visible options are selected. + * + * @param {Boolean} justVisible + * @param {Boolean} triggerOnSelectAll + */ + selectAll: function (justVisible, triggerOnSelectAll) { + + var justVisible = typeof justVisible === 'undefined' ? true : justVisible; + var allLis = $("li:not(.divider):not(.disabled):not(.multiselect-group)", this.$ul); + var visibleLis = $("li:not(.divider):not(.disabled):not(.multiselect-group):not(.multiselect-filter-hidden):not(.multiselect-collapisble-hidden)", this.$ul).filter(':visible'); + + if(justVisible) { + $('input:enabled' , visibleLis).prop('checked', true); + visibleLis.addClass(this.options.selectedClass); + + $('input:enabled' , visibleLis).each($.proxy(function(index, element) { + var value = $(element).val(); + var option = this.getOptionByValue(value); + $(option).prop('selected', true); + }, this)); + } + else { + $('input:enabled' , allLis).prop('checked', true); + allLis.addClass(this.options.selectedClass); + + $('input:enabled' , allLis).each($.proxy(function(index, element) { + var value = $(element).val(); + var option = this.getOptionByValue(value); + $(option).prop('selected', true); + }, this)); + } + + $('li input[value="' + this.options.selectAllValue + '"]', this.$ul).prop('checked', true); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + if (triggerOnSelectAll) { + this.options.onSelectAll(); + } + }, + + /** + * Deselects all options. + * + * If justVisible is true or not specified, only visible options are deselected. + * + * @param {Boolean} justVisible + */ + deselectAll: function (justVisible, triggerOnDeselectAll) { + + var justVisible = typeof justVisible === 'undefined' ? true : justVisible; + var allLis = $("li:not(.divider):not(.disabled):not(.multiselect-group)", this.$ul); + var visibleLis = $("li:not(.divider):not(.disabled):not(.multiselect-group):not(.multiselect-filter-hidden):not(.multiselect-collapisble-hidden)", this.$ul).filter(':visible'); + + if(justVisible) { + $('input[type="checkbox"]:enabled' , visibleLis).prop('checked', false); + visibleLis.removeClass(this.options.selectedClass); + + $('input[type="checkbox"]:enabled' , visibleLis).each($.proxy(function(index, element) { + var value = $(element).val(); + var option = this.getOptionByValue(value); + $(option).prop('selected', false); + }, this)); + } + else { + $('input[type="checkbox"]:enabled' , allLis).prop('checked', false); + allLis.removeClass(this.options.selectedClass); + + $('input[type="checkbox"]:enabled' , allLis).each($.proxy(function(index, element) { + var value = $(element).val(); + var option = this.getOptionByValue(value); + $(option).prop('selected', false); + }, this)); + } + + $('li input[value="' + this.options.selectAllValue + '"]', this.$ul).prop('checked', false); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + if (triggerOnDeselectAll) { + this.options.onDeselectAll(); + } + }, + + /** + * Rebuild the plugin. + * + * Rebuilds the dropdown, the filter and the select all option. + */ + rebuild: function() { + this.$ul.html(''); + + // Important to distinguish between radios and checkboxes. + this.options.multiple = this.$select.attr('multiple') === "multiple"; + + this.buildSelectAll(); + this.buildDropdownOptions(); + this.buildFilter(); + + this.updateButtonText(); + this.updateSelectAll(true); + + if (this.options.enableClickableOptGroups && this.options.multiple) { + this.updateOptGroups(); + } + + if (this.options.disableIfEmpty && $('option', this.$select).length <= 0) { + this.disable(); + } + else { + this.enable(); + } + + if (this.options.dropRight) { + this.$ul.addClass('pull-right'); + } + }, + + /** + * The provided data will be used to build the dropdown. + */ + dataprovider: function(dataprovider) { + + var groupCounter = 0; + var $select = this.$select.empty(); + + $.each(dataprovider, function (index, option) { + var $tag; + + if ($.isArray(option.children)) { // create optiongroup tag + groupCounter++; + + $tag = $('<optgroup/>').attr({ + label: option.label || 'Group ' + groupCounter, + disabled: !!option.disabled, + value: option.value + }); + + forEach(option.children, function(subOption) { // add children option tags + var attributes = { + value: subOption.value, + label: subOption.label || subOption.value, + title: subOption.title, + selected: !!subOption.selected, + disabled: !!subOption.disabled + }; + + //Loop through attributes object and add key-value for each attribute + for (var key in subOption.attributes) { + attributes['data-' + key] = subOption.attributes[key]; + } + //Append original attributes + new data attributes to option + $tag.append($('<option/>').attr(attributes)); + }); + } + else { + + var attributes = { + 'value': option.value, + 'label': option.label || option.value, + 'title': option.title, + 'class': option['class'], + 'selected': !!option['selected'], + 'disabled': !!option['disabled'] + }; + //Loop through attributes object and add key-value for each attribute + for (var key in option.attributes) { + attributes['data-' + key] = option.attributes[key]; + } + //Append original attributes + new data attributes to option + $tag = $('<option/>').attr(attributes); + + $tag.text(option.label || option.value); + } + + $select.append($tag); + }); + + this.rebuild(); + }, + + /** + * Enable the multiselect. + */ + enable: function() { + this.$select.prop('disabled', false); + this.$button.prop('disabled', false) + .removeClass('disabled'); + }, + + /** + * Disable the multiselect. + */ + disable: function() { + this.$select.prop('disabled', true); + this.$button.prop('disabled', true) + .addClass('disabled'); + }, + + /** + * Set the options. + * + * @param {Array} options + */ + setOptions: function(options) { + this.options = this.mergeOptions(options); + }, + + /** + * Merges the given options with the default options. + * + * @param {Array} options + * @returns {Array} + */ + mergeOptions: function(options) { + return $.extend(true, {}, this.defaults, this.options, options); + }, + + /** + * Checks whether a select all checkbox is present. + * + * @returns {Boolean} + */ + hasSelectAll: function() { + return $('li.multiselect-all', this.$ul).length > 0; + }, + + /** + * Update opt groups. + */ + updateOptGroups: function() { + var $groups = $('li.multiselect-group', this.$ul) + var selectedClass = this.options.selectedClass; + + $groups.each(function() { + var $options = $(this).nextUntil('li.multiselect-group') + .not('.multiselect-filter-hidden') + .not('.disabled'); + + var checked = true; + $options.each(function() { + var $input = $('input', this); + + if (!$input.prop('checked')) { + checked = false; + } + }); + + if (selectedClass) { + if (checked) { + $(this).addClass(selectedClass); + } + else { + $(this).removeClass(selectedClass); + } + } + + $('input', this).prop('checked', checked); + }); + }, + + /** + * Updates the select all checkbox based on the currently displayed and selected checkboxes. + */ + updateSelectAll: function(notTriggerOnSelectAll) { + if (this.hasSelectAll()) { + var allBoxes = $("li:not(.multiselect-item):not(.multiselect-filter-hidden):not(.multiselect-group):not(.disabled) input:enabled", this.$ul); + var allBoxesLength = allBoxes.length; + var checkedBoxesLength = allBoxes.filter(":checked").length; + var selectAllLi = $("li.multiselect-all", this.$ul); + var selectAllInput = selectAllLi.find("input"); + + if (checkedBoxesLength > 0 && checkedBoxesLength === allBoxesLength) { + selectAllInput.prop("checked", true); + selectAllLi.addClass(this.options.selectedClass); + } + else { + selectAllInput.prop("checked", false); + selectAllLi.removeClass(this.options.selectedClass); + } + } + }, + + /** + * Update the button text and its title based on the currently selected options. + */ + updateButtonText: function() { + var options = this.getSelected(); + + // First update the displayed button text. + if (this.options.enableHTML) { + $('.multiselect .multiselect-selected-text', this.$container).html(this.options.buttonText(options, this.$select)); + } + else { + $('.multiselect .multiselect-selected-text', this.$container).text(this.options.buttonText(options, this.$select)); + } + + // Now update the title attribute of the button. + $('.multiselect', this.$container).attr('title', this.options.buttonTitle(options, this.$select)); + }, + + /** + * Get all selected options. + * + * @returns {jQUery} + */ + getSelected: function() { + return $('option', this.$select).filter(":selected"); + }, + + /** + * Gets a select option by its value. + * + * @param {String} value + * @returns {jQuery} + */ + getOptionByValue: function (value) { + + var options = $('option', this.$select); + var valueToCompare = value.toString(); + + for (var i = 0; i < options.length; i = i + 1) { + var option = options[i]; + if (option.value === valueToCompare) { + return $(option); + } + } + }, + + /** + * Get the input (radio/checkbox) by its value. + * + * @param {String} value + * @returns {jQuery} + */ + getInputByValue: function (value) { + + var checkboxes = $('li input:not(.multiselect-search)', this.$ul); + var valueToCompare = value.toString(); + + for (var i = 0; i < checkboxes.length; i = i + 1) { + var checkbox = checkboxes[i]; + if (checkbox.value === valueToCompare) { + return $(checkbox); + } + } + }, + + /** + * Used for knockout integration. + */ + updateOriginalOptions: function() { + this.originalOptions = this.$select.clone()[0].options; + }, + + asyncFunction: function(callback, timeout, self) { + var args = Array.prototype.slice.call(arguments, 3); + return setTimeout(function() { + callback.apply(self || window, args); + }, timeout); + }, + + setAllSelectedText: function(allSelectedText) { + this.options.allSelectedText = allSelectedText; + this.updateButtonText(); + } + }; + + $.fn.multiselect = function(option, parameter, extraOptions) { + return this.each(function() { + var data = $(this).data('multiselect'); + var options = typeof option === 'object' && option; + + // Initialize the multiselect. + if (!data) { + data = new Multiselect(this, options); + $(this).data('multiselect', data); + } + + // Call multiselect method. + if (typeof option === 'string') { + data[option](parameter, extraOptions); + + if (option === 'destroy') { + $(this).data('multiselect', false); + } + } + }); + }; + + $.fn.multiselect.Constructor = Multiselect; + + $(function() { + $("select[data-role=multiselect]").multiselect(); + }); + +}); diff --git a/basetheme_bootstrap/templates/403.html b/basetheme_bootstrap/templates/403.html new file mode 100644 index 0000000000000000000000000000000000000000..64c66017bc58d562a5e652ac4fcc17f016cd7bb8 --- /dev/null +++ b/basetheme_bootstrap/templates/403.html @@ -0,0 +1,6 @@ +{% extends "basetheme_bootstrap/simple_message_page.html" %} +{% load i18n %} + +{% block title %}{%trans "Error 403" %}{% endblock %} +{% block page_title %}{%trans "Error 403" %}{% endblock %} +{% block message %}{%trans "Your are not authorised to access this resource." %}{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/404.html b/basetheme_bootstrap/templates/404.html new file mode 100644 index 0000000000000000000000000000000000000000..8f4aa40502de3a7837b41a90555d74b8e5d42bc7 --- /dev/null +++ b/basetheme_bootstrap/templates/404.html @@ -0,0 +1,6 @@ +{% extends "basetheme_bootstrap/simple_message_page.html" %} +{% load i18n %} + +{% block title %}{%trans "Error 404" %}{% endblock %} +{% block page_title %}{%trans "Error 404" %}{% endblock %} +{% block message %}{%trans "The resource you tried to access cannot be found." %}{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/500.html b/basetheme_bootstrap/templates/500.html new file mode 100644 index 0000000000000000000000000000000000000000..ac814a9cccac3eaac887a7fb854f9cbef553a3cd --- /dev/null +++ b/basetheme_bootstrap/templates/500.html @@ -0,0 +1,7 @@ +{% extends "basetheme_bootstrap/simple_message_page.html" %} +{% load i18n %} + +{% block title %}{%trans "Error 500" %}{% endblock %} +{% block page_title %}{%trans "Error 500" %}{% endblock %} +{% block message %}{%trans "Your request cannot be process due to internal error." %}{% endblock %} +{% block sub_message %}{%trans "You might want to contact the administrators." %}{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/about.example.html b/basetheme_bootstrap/templates/basetheme_bootstrap/about.example.html new file mode 100644 index 0000000000000000000000000000000000000000..ea9f3481de7e232f232dacd4f04f912698bfde62 --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/about.example.html @@ -0,0 +1,33 @@ +{% extends basetheme_bootstrap_base_template %} +{% load i18n %} +{% block title %}{%trans "About"%}{% endblock %} +{% block page_title %}{%trans "About"%}{% endblock %} +{% block content%} +<div class="col-xs-12 col-12"> + <h2>{%trans "Team"%}</h2> + <table class="table"> + <thead> + <tr> + <th>{%trans "Last name"%}</th> + <th>{%trans "First name"%}</th> + <th>{%trans "Contact"%}</th> + </tr> + </thead> + <tbody> + <tr> + <td> + BRANCOTTE + </td> + <td> + Bryan + </td> + <td> + <a target="_blank" + href="https://orcid.org/0000-0001-8669-5525">https://orcid.org/0000-0001-8669-5525</a><br/> + <a target="_blank" href="https://research.pasteur.fr/en/member/bryan-brancotte">https://research.pasteur.fr/en/member/bryan-brancotte</a><br/> + </td> + </tr> + </tbody> + </table> +</div> +{% endblock%} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/base.html b/basetheme_bootstrap/templates/basetheme_bootstrap/base.html new file mode 100644 index 0000000000000000000000000000000000000000..ca0982ea5e16c627e160b016e83b75d07bd3a737 --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/base.html @@ -0,0 +1,144 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +{% load static %} +{% load sstatic %} +{% load basetheme_bootstrap %} +{% load i18n %} +<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"> +<head> + {% block ref_google %}{% endblock %} + <title>{% block title %}{% endblock %}</title> + <!-- Required meta tags --> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <link rel="icon" href="{% static '/img/favicon.png'%}"> + + <script + src="https://code.jquery.com/jquery-3.3.1.min.js" + integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" + crossorigin="anonymous"></script> + <script + src="https://code.jquery.com/color/jquery.color.plus-names-2.1.2.min.js" + integrity="sha256-Wp3wC/dKYQ/dCOUD7VUXXp4neLI5t0uUEF1pg0dFnAE=" + crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.0.0/bootstrap-slider.min.js"></script> + + <!-- Bootstrap CSS --> + <!-- Latest compiled and minified CSS --> + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" + integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> + + <!-- Optional theme --> + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" + integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous"> + + <link href="https://gitcdn.github.io/bootstrap-toggle/2.2.2/css/bootstrap-toggle.min.css" rel="stylesheet"> + <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"> + <link href="https://cdn.datatables.net/1.10.16/css/jquery.dataTables.min.css" rel="stylesheet"> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.0.0/css/bootstrap-slider.min.css"/> + <link rel="stylesheet" href="{% static '/css/bootstrap-multiselect.css' %}" type="text/css"/> + <link rel="stylesheet" href="{% static '/css/bootstrap-xlgrid.css' %}"/> + <link rel="stylesheet" href="{% static '/css/basetheme_bootstrap.css' %}"/> + {% block extra_css %}{% endblock %} + + <!-- Latest compiled and minified JavaScript --> + <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" + integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" + crossorigin="anonymous"></script> + <script src="https://gitcdn.github.io/bootstrap-toggle/2.2.2/js/bootstrap-toggle.min.js"></script> + <script src="https://cdn.datatables.net/1.10.16/js/jquery.dataTables.js"></script> + <script src="https://cdn.datatables.net/1.10.19/js/jquery.dataTables.min.js"></script> + <script src="https://cdn.datatables.net/buttons/1.5.2/js/dataTables.buttons.min.js"></script> + <script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.flash.min.js"></script> + <script type="text/javascript" src="{% static '/js/bootstrap-multiselect.js' %}"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.3/jszip.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script> + <script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.html5.min.js"></script> + <script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.print.min.js"></script> + <script src="{% static '/js/basetheme_bootstrap.js' %}"></script> + {% block extra_js %}{% endblock %} + +</head> +<body> +{% include_if_exists "fork_me.html"|localize_template "basetheme_bootstrap/fork_me.example.html" %} +<nav class="navbar navbar-default navbar-fixed-top"> + <div class="container-fluid"> + <div class="navbar-header"> + <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" + aria-expanded="false" aria-controls="navbar"> + <span class="sr-only">{%trans "Toggle navigation"%}</span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + </button> + <a class="navbar-brand" href="{% url 'home'%}">{% block project_name %}{% endblock %}</a> + </div> + <div id="navbar" class="collapse navbar-collapse"> + <ul class="nav navbar-nav"> + <li class="{{ request|is_active:'home'}}"><a href="{% url 'home'%}">{%trans "Home"%}</a></li> + {% include_if_exists "nav_bar.html"|localize_template "basetheme_bootstrap/nav_bar.example.html" %} + </ul> + <ul class="nav navbar-nav navbar-right"> + {%if not request.user.is_authenticated %} + <li class=""><a href="{% url 'basetheme_bootstrap:login'%}?next={{ request.path|urlencode }}"><i class="fa fa-sign-in"></i></a></li> + {%else%} + {% block extra_li_dropdown_right %}{% endblock %} + <li class="dropdown"> + <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" + aria-expanded="false"><i class="fa fa-user-circle-o"></i> <span class="caret"></span></a> + <ul class="dropdown-menu"> + {%if request.user.is_staff %} + <li><a href="/admin/"><i class="fa fa-cogs"></i> {%trans "Admin"%}</a></li> + <li class="divider"></li> + {%endif%} + <li><a href="{% url 'basetheme_bootstrap:account'%}"><i class="fa fa-user-circle-o"></i> {%trans "Account"%}</a></li> + <li><a href="{% url 'basetheme_bootstrap:logout'%}"><i class="fa fa-sign-out"></i> {%trans "Logout"%}</a></li> + </ul> + </li> + {%endif%} + </ul> + </div><!--/.nav-collapse --> + </div> +</nav> + +<main role="main" class="{% block container-class %}container{% endblock %}"> + <div class="row"> + <div class="page-header"> + {% block content_page_title %} + <div class="col-xs-12"> + <h1>{% block page_title %}{% endblock %}</h1> + </div> + {% endblock %} + <div class="col-xs-12"> + <hr> + </div> + </div> + {% if messages %} + <div class="col-xs-12 "> + {% for message in messages %} + <p class="alert alert-{{ message.tags|tags_to_bootstrap }}"> + {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %} + {{ message|linebreaksbr}} + </p> + {% endfor %} + </div> + <div class="col-xs-12"> + <hr> + </div> + {% endif %} + <div class="clearfix"></div> + {% block content %}{% endblock %} + </div> +</main> + + <footer class="footer"> + <div class="container"> + <!--<span class="text-muted">Place sticky footer content here.</span>--> + <span class="text-muted pull-right"> + {% include_if_exists "last_update.html"|localize_template "basetheme_bootstrap/last_update.example.html" %} + </span> + </div> + </footer> +</body> +</html> \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/base4.html b/basetheme_bootstrap/templates/basetheme_bootstrap/base4.html new file mode 100644 index 0000000000000000000000000000000000000000..5cad61978f40a8d78022503fb113148282ee172e --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/base4.html @@ -0,0 +1,138 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +{% load static %} +{% load sstatic %} +{% load basetheme_bootstrap %} +{% load i18n %} +<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" class="h-100"> +<head> + {% block ref_google %}{% endblock %} + <title>{% block title %}{% endblock %}</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <link rel="icon" href="{% block favicon %}/static/img/favicon.png{% endblock %}"> + + <!-- Bootstrap core CSS --> + <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" + integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> + <link href="https://cdn.jsdelivr.net/gh/gitbrent/bootstrap4-toggle@3.4.0/css/bootstrap4-toggle.min.css" rel="stylesheet"> + <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"> + <link href="https://cdn.datatables.net/1.10.16/css/jquery.dataTables.min.css" rel="stylesheet"> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.0.0/css/bootstrap-slider.min.css"/> + <link rel="stylesheet" href="{% static '/css/bootstrap-multiselect.css' %}" type="text/css"/> + <link rel="stylesheet" href="{% static '/css/basetheme_bootstrap4.css' %}"/> + {% block extra_css %}{% endblock %} +</head> +<body class="d-flex flex-column h-100"> +{% include_if_exists "fork_me.html"|localize_template "basetheme_bootstrap/fork_me.example.html" %} +<header> + <!-- Fixed navbar --> + <nav class="navbar navbar-expand-md fixed-top navbar-light bg-light"> + <a class="navbar-brand" href="{% url 'home'%}">{% block project_name %}{% endblock %}</a> + <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" + aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + <div class="collapse navbar-collapse" id="navbarCollapse"> + <ul class="navbar-nav mr-auto"> + {% include_if_exists "nav_bar.html"|localize_template "basetheme_bootstrap/nav_bar.example.html" %} + </ul> + <ul class="nav navbar-nav navbar-right"> + {%if not request.user.is_authenticated %} + <li class="nav-item"><a class="nav-link" href="{% url 'basetheme_bootstrap:login'%}?next={{ request.path|urlencode }}"><i class="fa fa-sign-in"></i></a></li> + {%else%} + {% block extra_li_dropdown_right %}{% endblock %} + <li class="nav-item dropdown"> + <a class="nav-link dropdown-toggle" + href="#" + id="dropdown-user" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + ><i class="fa fa-user-circle-o"></i> <span class="caret"></span></a> + <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-user"> + {%if request.user.is_staff %} + <a class="dropdown-item" href="/admin/"><i class="fa fa-cogs"></i> {%trans "Admin"%}</a> + <div class="dropdown-divider"></div> + {%endif%} + <a class="dropdown-item" href="{% url 'basetheme_bootstrap:account'%}"><i class="fa fa-user-circle-o"></i> {%trans "Account"%}</a> + <a class="dropdown-item" href="{% url 'basetheme_bootstrap:logout'%}"><i class="fa fa-sign-out"></i> {%trans "Logout"%}</a> + </div> + </li> + {%endif%} + </ul> + {% block form_in_nav_bar %} + <!--<form class="form-inline mt-2 mt-md-0">--> + <!--<input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search">--> + <!--<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>--> + <!--</form>--> + {% endblock %} + </div> + </nav> +</header> +<!-- Begin page content --> +<main role="main" class="flex-shrink-0"> + <div class="{% block container-class %}container mb-4{% endblock %}"> + <div class="row"> + {% block whole_content_page_title %} + <div class="pb-2 mt-4 mb-2 col-12"> + {% block content_page_title %} + <div class="border-bottom"> + <h1>{% block page_title %}{% endblock %}</h1> + </div> + {% endblock %} + </div> + {% endblock %} + {% if messages %} + <div class="pb-2 mb-2 col-12"> + <div class="border-bottom"> + {% for message in messages %} + <p class="alert alert-{{ message.tags|tags_to_bootstrap }}"> + {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %} + {{ message|linebreaksbr}} + </p> + {% endfor %} + </div> + </div> + {% endif %} + {% block content %}{% endblock %} + </div> + </div> +</main> + +<footer class="footer mt-auto py-3"> + <div class="container"> + <span class="text-muted float-right"> + {% include_if_exists "last_update.html"|localize_template "basetheme_bootstrap/last_update.example.html" %} + </span> + </div> +</footer> +</body> +<script + src="https://code.jquery.com/jquery-3.3.1.min.js" + integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" + crossorigin="anonymous"></script> +<script + src="https://code.jquery.com/color/jquery.color.plus-names-2.1.2.min.js" + integrity="sha256-Wp3wC/dKYQ/dCOUD7VUXXp4neLI5t0uUEF1pg0dFnAE=" + crossorigin="anonymous"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" + integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" + crossorigin="anonymous"></script> +<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" + integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" + crossorigin="anonymous"></script> +<script src="https://cdn.jsdelivr.net/gh/gitbrent/bootstrap4-toggle@3.4.0/js/bootstrap4-toggle.min.js"></script> +<script src="https://cdn.datatables.net/1.10.16/js/jquery.dataTables.js"></script> +<script src="https://cdn.datatables.net/1.10.19/js/jquery.dataTables.min.js"></script> +<script src="https://cdn.datatables.net/buttons/1.5.2/js/dataTables.buttons.min.js"></script> +<script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.flash.min.js"></script> +<script type="text/javascript" src="{% static '/js/bootstrap-multiselect.js' %}"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.3/jszip.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script> +<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script> +<script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.html5.min.js"></script> +<script src="https://cdn.datatables.net/buttons/1.5.2/js/buttons.print.min.js"></script> +<script src="{% static '/js/basetheme_bootstrap.js' %}"></script> +{% block extra_js %}{% endblock %} +</html> diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/fork_me.example.html b/basetheme_bootstrap/templates/basetheme_bootstrap/fork_me.example.html new file mode 100644 index 0000000000000000000000000000000000000000..279cb3a4ea018bfa1abc0918b145b9a0fa01db7e --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/fork_me.example.html @@ -0,0 +1,6 @@ +{% load i18n %} +<!-- **** Begin Fork-Me-On-Gitlab-Ribbon-HTML. See MIT License at https://gitlab.com/seanwasere/fork-me-on-gitlab **** --> +<a href="[Your Repository URL Here]"> + <span class="fork_me">{%trans "Fork Me"%}</span> +</a> +<!-- **** End Fork-Me-On-Gitlab-Ribbon-HTML **** --> \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/form_host.html b/basetheme_bootstrap/templates/basetheme_bootstrap/form_host.html new file mode 100644 index 0000000000000000000000000000000000000000..af8553bdfe95742865f3ab253080f39887ad4bf5 --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/form_host.html @@ -0,0 +1,40 @@ +{% extends basetheme_bootstrap_base_template %} +{% load crispy_forms_tags %} +{% load static %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ page_title|default:title }}{% endblock %} + +{% block content %} +<div class="col-12 col-xs-12"> + {%if form_title%}<h2>{{form_title}}</h2>{%endif%} + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + {%if form%} + {%if form.forms%} + {{ form.management_form }} + <div class="row col-12"> + {% for f in form.forms %} + <div class="{{ formset_css_classes|default:'mb-4'}}"> + <div class="card"> + <div class="card-body"> + {{ f|crispy }} + </div> + </div> + </div> + {%endfor%} + </div> + {%else%} + {{ form|crispy }} + {%endif%} + {%endif%} + {%if cancel_url%} + <a role="button" class="btn btn-primary" href="{{cancel_url}}">{{ cancel_text|default:"Cancel"}}</a> + {%endif%} + <div class="form-class {{btn_container_classes|default:'pull-right'}}"> + <button class="btn btn-primary {{btn_classes}}" type="submit">{{ submit_text|default:"OK" }}</button> + </div> + </form> +</div> +{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/last_update.example.html b/basetheme_bootstrap/templates/basetheme_bootstrap/last_update.example.html new file mode 100644 index 0000000000000000000000000000000000000000..55852e26a5d9fdf867cefaedd63e42de7515f71b --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/last_update.example.html @@ -0,0 +1 @@ +In the <i>last_update.html</i> you can for example use a git hook (post-commit and post-merge) running: "<code>echo "Version $(git describe --tags) on $(git show -s --format=%ci)" > ...../templates/my_project/last_update.html</code>" and you will get "Version v1.0.1-40-gc2bc714 on 2018-10-09 19:39:01 +0200" diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/nav_bar.example.html b/basetheme_bootstrap/templates/basetheme_bootstrap/nav_bar.example.html new file mode 100644 index 0000000000000000000000000000000000000000..40093b3d40450aaa2512f2be3fbec17a46b03a71 --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/nav_bar.example.html @@ -0,0 +1,3 @@ +{% load basetheme_bootstrap %} +{% load i18n %} +<li class="nav-item {{ request|is_active_or_desc:'basetheme_bootstrap:about_page'}}"><a href="{% url 'basetheme_bootstrap:about_page'%}" class="nav-link">{%trans "About"%}</a></li> \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/simple_message_page.html b/basetheme_bootstrap/templates/basetheme_bootstrap/simple_message_page.html new file mode 100644 index 0000000000000000000000000000000000000000..39984aca83f5725600ddd3985aa4f9b95afeeb69 --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/simple_message_page.html @@ -0,0 +1,19 @@ +{% extends basetheme_bootstrap_base_template %} +{% load i18n %} + +{% block whole_content_page_title %} +<div class="pb-2 mt-4 mb-2 col-12"> + {% block content_page_title %} + <div class="border-bottom text-center"> + <h1>{% block page_title %}{% endblock %}</h1> + </div> + {% endblock %} + <h2 class="mt-4 text-center">{% block message %}{% endblock %}</h2> +</div> +{% endblock %} + +{% block content %} +<div class="col-xs-12 col-12 text-center"> + {% block sub_message %}{% endblock %} +</div> +{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/basetheme_bootstrap/small_form_host.html b/basetheme_bootstrap/templates/basetheme_bootstrap/small_form_host.html new file mode 100644 index 0000000000000000000000000000000000000000..ff20f58e8a026a0e98ad51bc2e18e2222e738c0a --- /dev/null +++ b/basetheme_bootstrap/templates/basetheme_bootstrap/small_form_host.html @@ -0,0 +1 @@ +{% extends 'registration/small_form_host.html' %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/registration/account.html b/basetheme_bootstrap/templates/registration/account.html new file mode 100644 index 0000000000000000000000000000000000000000..0961e0888e65333b37879e59f7720015a1972704 --- /dev/null +++ b/basetheme_bootstrap/templates/registration/account.html @@ -0,0 +1,42 @@ +{% extends basetheme_bootstrap_base_template %} +{% load crispy_forms_tags %} +{% load static %} +{% load sstatic %} +{% load i18n %} + +{% block title %}{%trans "Account"%}{% endblock %} +{% block page_title %}{%trans "Account"%}{% endblock %} + + +{% block content %} +<div class="col-12 col-xs-12 col-md-7 col-lg-8 col-xl-9 mb-sm-4 mb-md-0"> + <div class="card panel panel-default"> + <div class="card-header panel-heading"> + <h3 class="panel-title">{%trans "Preferences"%}</h3> + </div> + <div class="card-body panel-body"> + {%if form_prefs%} + <form method="post"> + {% csrf_token %} + {{ form_prefs|crispy }} + <button class="btn btn-primary {{btn_classes}}" type="submit">Save my preferences + </button> + </form> + {%endif%} + </div> + </div> + +</div> +<div class="col-12 col-xs-12 col-md-5 col-lg-4 col-xl-3"> + <div class="card panel panel-default"> + <div class="card-header panel-heading"> + <h3 class="panel-title">{%trans "Actions"%}</h3> + </div> + <div class="card-body panel-body text-center"> + <a role="button" class="btn btn-default btn-outline-primary btn-block" href="{% url 'basetheme_bootstrap:change_password'%}">{%trans "Change password"%}</a> + <a role="button" class="btn btn-default btn-outline-primary btn-block" href="{% url 'basetheme_bootstrap:user-update'%}">{%trans "Update account"%}</a> + <a role="button" class="btn btn-block btn-danger" href="{% url 'basetheme_bootstrap:user-delete'%}">{%trans "Delete your account"%}</a> + </div> + </div> +</div> +{% endblock %} diff --git a/basetheme_bootstrap/templates/registration/login.html b/basetheme_bootstrap/templates/registration/login.html new file mode 100644 index 0000000000000000000000000000000000000000..1738c78b214a4eed3770245295410c94de5befec --- /dev/null +++ b/basetheme_bootstrap/templates/registration/login.html @@ -0,0 +1,20 @@ +{% extends basetheme_bootstrap_base_template %} +{% load crispy_forms_tags %} +{% load static %} +{% load i18n %} + +{% block title %}{%trans "Login"%}{% endblock %} +{% block page_title %}{%trans "Login"%}{% endblock %} + +{% block content %} +<div class="col-xs-12 col-12 col-sm-10 col-sm-offset-1 offset-sm-1 col-md-8 col-md-offset-2 offset-md-2 col-lg-6 col-lg-offset-3 offset-lg-3 col-xl-4 col-xl-offset-4 offset-xl-4 col-xxl-2"> + <form method="post"> + {% csrf_token %} + {{ form|crispy }} + <button class="btn btn-primary full-width" type="submit">{%trans "Login"%}</button> + </form> + <br/> + <hr/> + <a href="{% url 'basetheme_bootstrap:signup' %}" role="button" class="btn btn-default btn-outline-primary full-width">{%trans "Create account"%}</a> +</div> +{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/registration/signup.html b/basetheme_bootstrap/templates/registration/signup.html new file mode 100644 index 0000000000000000000000000000000000000000..dee34a9860577b728e6755c060490a380568b030 --- /dev/null +++ b/basetheme_bootstrap/templates/registration/signup.html @@ -0,0 +1 @@ +{%include 'registration/small_form_host.html' with title='Sign up' submit_text='Sign up' form=form%} \ No newline at end of file diff --git a/basetheme_bootstrap/templates/registration/small_form_host.html b/basetheme_bootstrap/templates/registration/small_form_host.html new file mode 100644 index 0000000000000000000000000000000000000000..84afc869ec97f1bc83c6696a724a9bf4094aab33 --- /dev/null +++ b/basetheme_bootstrap/templates/registration/small_form_host.html @@ -0,0 +1,21 @@ +{% extends basetheme_bootstrap_base_template %} +{% load crispy_forms_tags %} +{% load static %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ page_title|default:title }}{% endblock %} + +{% block content %} +<div class="col-12 col-xs-12 {%if not medium_width%}col-sm-10 col-sm-offset-1 offset-sm-1 col-md-8 col-md-offset-2 offset-md-2 col-lg-6 col-lg-offset-3 offset-lg-3 col-xl-4 col-xl-offset-4 offset-xl-4 col-xxl-2 col-xxl-offset-5{%endif%}{%if medium_width%}col-md-10 col-md-offset-1 offset-md-1 col-lg-8 col-lg-offset-2 offset-lg-2 col-xl-6 col-xl-offset-3 offset-xl-3 col-xxl-4 col-xxl-offset-4 col-xxxl-2 col-xxxl-offset-5{%endif%}"> + {%if form_title%}<h2>{{form_title}}</h2>{%endif%} + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + {%if form%}{{ form|crispy }}{%endif%} + {%if cancel_url%}<a role="button" class="btn btn-primary" href="{{cancel_url}}">{{ cancel_text|default:"Cancel" }}</a>{%endif%} + <div class="form-class {{btn_container_classes|default:'pull-right'}}"> + <button class="btn btn-primary {{btn_classes}}" type="submit">{{ submit_text|default:"OK" }}</button> + </div> + </form> +</div> +{% endblock %} \ No newline at end of file diff --git a/basetheme_bootstrap/templatetags/basetheme_bootstrap.py b/basetheme_bootstrap/templatetags/basetheme_bootstrap.py new file mode 100644 index 0000000000000000000000000000000000000000..199d817726e83225dc9c8b4debdc4628df714d3c --- /dev/null +++ b/basetheme_bootstrap/templatetags/basetheme_bootstrap.py @@ -0,0 +1,101 @@ +import logging + +from django import template +from django.conf import settings +from django.template.base import Token +from django.template.loader import get_template +from django.template.loader_tags import do_include +from django.urls.base import reverse + +register = template.Library() + +logger = logging.getLogger(__name__) + + +@register.filter +def is_active_or_desc(request, pattern): + try: + if str(request.path).startswith(str(reverse(pattern))) \ + or str(request.path).startswith(str(pattern)): + return 'active ' + except Exception: + pass + return '' + + +@register.filter +def is_active(request, pattern): + try: + if str(reverse(pattern)) == str(request.path) \ + or str(pattern) == str(request.path): + return 'active ' + except Exception: + pass + return '' + + +@register.filter +def tags_to_bootstrap(tag): + if tag == "error": + return "danger" + if tag == "": + return "info" + return tag + + +class IncludeIfExistsNode(template.Node): + """ + A Node that instantiates an IncludeNode but wraps its render() in a + try/except in case the template doesn't exist. + """ + + def __init__(self, parser, token): + split_contents = token.split_contents() + self.template_name = split_contents[1] + self.template_node = do_include(parser, Token(token.token_type, 'do_include %s' % split_contents[1])) + self.default_template_name = None + self.default_template_node = None + try: + self.default_template_name = split_contents[2] + self.default_template_node = do_include(parser, + Token(token.token_type, 'do_include %s' % split_contents[2])) + except IndexError: + pass + + def render(self, context): + try: + return self.template_node.render(context) + except template.TemplateDoesNotExist: + logger.warning( + 'Template %s was not found and could not be included.' % self.template_name + + ( + ('Please see %s to have an example' % self.default_template_name) + if self.default_template_name else '' + ) + ) + if self.default_template_node: + return self.default_template_node.render(context) + return '' + + +@register.tag +def include_if_exists(parser, token): + """ + Include the specified template but only if it exists. + """ + return IncludeIfExistsNode(parser, token) + + +@register.filter +def localize_template(template_name): + return settings.BASETHEME_BOOTSTRAP_TEMPLATE_LOCATION_PROJECT + "/" + template_name + + +@register.filter +def basetheme_bootstrap_template_if_not_redefined(template_name): + localized_template_name = localize_template(template_name) + try: + template.loader.get_template(localized_template_name) + return localized_template_name + except template.TemplateDoesNotExist: + return "basetheme_bootstrap/" + template_name diff --git a/basetheme_bootstrap/templatetags/sstatic.py b/basetheme_bootstrap/templatetags/sstatic.py new file mode 100644 index 0000000000000000000000000000000000000000..4bfe37c4f77773af84c1ee2a4868399c5b5d588c --- /dev/null +++ b/basetheme_bootstrap/templatetags/sstatic.py @@ -0,0 +1,26 @@ +import os +import random +import string + +from django import template +from django.conf import settings + +# credits : https://bitbucket.org/ad3w/django-sstatic + +register = template.Library() + + +@register.simple_tag +def sstatic(path): + ''' + Returns absolute URL to static file with versioning. + ''' + full_path = os.path.join(settings.STATIC_ROOT, path[1:] if path[0] == '/' else path) + try: + # Get file modification time. + mtime = os.path.getmtime(full_path) + return '%s%s?%s' % (settings.STATIC_URL[:-1], path, mtime) + except OSError: + # Returns normal url if this file was not found in filesystem. + return '%s%s?%s' % (settings.STATIC_URL[:-1], path, ''.join( + random.choice(''.join((string.ascii_letters, string.digits))) for _ in range(32))) diff --git a/basetheme_bootstrap/tests.py b/basetheme_bootstrap/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..76d5aca2e5281bdadd70121cf6565ef84c74d589 --- /dev/null +++ b/basetheme_bootstrap/tests.py @@ -0,0 +1,302 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from basetheme_bootstrap import user_preferences + + +class AboutPageTests(TestCase): + def test_works(self): + response = self.client.get(reverse('basetheme_bootstrap:about_page')) + self.assertEqual(response.status_code, 200) + + +class SignUpTests(TestCase): + + def test_works(self): + response = self.client.get(reverse('basetheme_bootstrap:signup')) + self.assertEqual(response.status_code, 200) + + def test_sign_up_form_view(self): + user_count = get_user_model().objects.count() + response = self.client.post(reverse('basetheme_bootstrap:signup'), { + 'username': "userAAA", + 'email': "userAAA@mp.com", + 'password1': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'password2': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'first_name': "user" + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, expected_url=reverse('home'), ) + self.assertEqual(get_user_model().objects.count(), user_count + 1) + + +class SuperuserSignUpTests(TestCase): + + def test_sign_up_for_superuser(self): + response = self.client.post(reverse('basetheme_bootstrap:signup'), { + 'username': "userAAA", + 'email': "bryan.brancotte@pasteur.fr", + 'password1': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'password2': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'first_name': "user" + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, expected_url=reverse('home'), ) + self.assertTrue(get_user_model().objects.get(username="userAAA").is_superuser) + + response = self.client.post(reverse('basetheme_bootstrap:signup'), { + 'username': "userAAAZZZ", + 'email': "userAAA@mp.com", + 'password1': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'password2': "user@mp.comuser@mp.comuser@mp.comuser@mp.com", + 'first_name': "user" + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, expected_url=reverse('home'), ) + self.assertEqual(get_user_model().objects.count(), 2) + self.assertFalse(get_user_model().objects.get(username="userAAAZZZ").is_superuser) + + +class ChangePasswordTests(TestCase): + + def setUp(self): + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user_pwd_2 = "aig7thah4eethahdaDae" + self.user = get_user_model().objects.create( + username="a", + email="a@a.a", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_works(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + response = self.client.get(reverse('basetheme_bootstrap:change_password')) + self.assertEqual(response.status_code, 200) + + def test_changepassword_ok_view(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + + response = self.client.post(reverse('basetheme_bootstrap:change_password'), { + 'old_password': self.user_pwd, + 'new_password1': self.user_pwd_2, + 'new_password2': self.user_pwd_2, + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, expected_url=reverse('basetheme_bootstrap:account'), ) + self.client.logout() + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd_2)) + + def test_changepassword_fail_view(self): + user_login = self.client.login(username="a", email="a@a.a", password=self.user_pwd) + self.assertTrue(user_login) + response = self.client.post(reverse('basetheme_bootstrap:change_password'), { + 'old_password': "A", + 'new_password1': "Z", + 'new_password2': "E", + }) + self.assertEqual(response.status_code, 200) + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + + user_login = self.client.login(username="a", email="a@a.a", password=self.user_pwd) + self.assertTrue(user_login) + response = self.client.post(reverse('basetheme_bootstrap:change_password'), { + 'old_password': self.user_pwd, + 'new_password1': "Z", + 'new_password2': "E", + }) + self.assertEqual(response.status_code, 200) + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + + +class ChangeFirstnameTests(TestCase): + + def setUp(self): + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user_pwd_2 = "aig7thah4eethahdaDae" + self.user = get_user_model().objects.create( + username="a", + email="aa@aa.aa", + first_name="tata", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_works(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + response = self.client.get(reverse('basetheme_bootstrap:user-update')) + self.assertEqual(response.status_code, 200) + + def test_empty_username(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + user = get_user_model().objects.get(username="a") + response = self.client.post(reverse('basetheme_bootstrap:user-update'), { + 'username': "", + 'email': user.email, + 'first_name': "toto", + }) + self.assertEqual(response.status_code, 200) + self.assertEqual(get_user_model().objects.get(pk=user.pk).username, "a") + + def test_change_firstname(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + + self.assertEqual(get_user_model().objects.get(username="a").first_name, "tata") + response = self.client.post(reverse('basetheme_bootstrap:user-update'), { + 'username': get_user_model().objects.get(username="a").username, + 'email': get_user_model().objects.get(username="a").email, + 'first_name': "toto", + }) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, expected_url=reverse('basetheme_bootstrap:account'), ) + self.client.logout() + self.assertEqual(get_user_model().objects.get(username="a").first_name, "toto") + + +class DeleteAccountTests(TestCase): + + def setUp(self): + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user_pwd_2 = "aig7thah4eethahdaDae" + self.user = get_user_model().objects.create( + username="a", + email="aa@aa.aa", + first_name="tata", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_works(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + response = self.client.get(reverse('basetheme_bootstrap:user-delete')) + self.assertEqual(response.status_code, 200) + + def test_works_post(self): + self.assertTrue(self.client.login(username="a", email="a@a.a", password=self.user_pwd)) + response = self.client.post(reverse('basetheme_bootstrap:user-delete'), {}) + self.assertEqual(response.status_code, 302) + self.assertIn(response.url, ['/', reverse('basetheme_bootstrap:login')]) + # self.assertRedirects(response, expected_url='/', ) + + +class TemplatesTagsTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def test_is_active_or_desc(self): + from basetheme_bootstrap.templatetags.basetheme_bootstrap import is_active_or_desc + request = self.factory.get(reverse("basetheme_bootstrap:change_password")) + self.assertEqual('active ', is_active_or_desc( + request, + "basetheme_bootstrap:account" + )) + self.assertEqual('active ', is_active_or_desc( + request, + "basetheme_bootstrap:change_password" + )) + request = self.factory.get(reverse("basetheme_bootstrap:account")) + self.assertEqual('', is_active_or_desc( + request, + "basetheme_bootstrap:change_password" + )) + self.assertEqual('', is_active_or_desc( + None, + "basetheme_bootstrap:account" + )) + self.assertEqual('', is_active_or_desc( + request, + None + )) + + def test_is_active(self): + from basetheme_bootstrap.templatetags.basetheme_bootstrap import is_active + request = self.factory.get(reverse("basetheme_bootstrap:change_password")) + self.assertEqual('', is_active( + request, + "basetheme_bootstrap:account" + )) + self.assertEqual('active ', is_active( + request, + "basetheme_bootstrap:change_password" + )) + request = self.factory.get(reverse("basetheme_bootstrap:account")) + self.assertEqual('', is_active( + request, + "basetheme_bootstrap:change_password" + )) + self.assertEqual('', is_active( + None, + "basetheme_bootstrap:account" + )) + self.assertEqual('', is_active( + request, + None + )) + + def test_tags_to_bootstrap(self): + from basetheme_bootstrap.templatetags.basetheme_bootstrap import tags_to_bootstrap + self.assertEqual(tags_to_bootstrap("error"), "danger") + self.assertEqual(tags_to_bootstrap("warning"), "warning") + self.assertEqual(tags_to_bootstrap(""), "info") + + +class UserPreferencesTests(TestCase): + + def setUp(self): + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user = get_user_model().objects.create( + username="user", + email="aa@aa.aa", + first_name="tata", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_when_enabled_that_it_works(self): + if not getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED", False): + return + self.assertIsNotNone(user_preferences.get_user_preference_class()) + pref = user_preferences.get_user_preferences_for_user(self.user) + self.assertIsNotNone(pref) + default_pref = user_preferences.get_user_preferences_for_user(None) + self.assertIsNotNone(default_pref) + self.assertNotEqual(pref, default_pref) + + def test_when_enabled_that_it_works_2(self): + if not getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED", False): + return + default_pref = user_preferences.get_user_preferences_for_user(None) + self.assertIsNotNone(default_pref) + pref = user_preferences.get_user_preferences_for_user(self.user) + self.assertIsNotNone(pref) + self.assertNotEqual(pref, default_pref) + + default_pref.user = self.user + default_pref.save() + default_pref.user = None + default_pref.save() + pref.save() + + default_pref = user_preferences.get_user_preferences_for_user(None) + self.assertIsNotNone(default_pref) + pref = user_preferences.get_user_preferences_for_user(self.user) + self.assertIsNotNone(pref) + self.assertNotEqual(pref, default_pref) + + def test_only_one_default_pref(self): + if not getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED", False): + return + pref = user_preferences.get_user_preferences_for_user(self.user) + pref.user = None + self.assertRaises(ValidationError, pref.save) + + def test_caching_disabled_status_when_it_is_the_case(self): + user_preferences.get_user_preference_class() + self.assertTrue( + cache.get("has_no_user_preferences_model", False) or + getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED", False) + ) diff --git a/basetheme_bootstrap/urls.py b/basetheme_bootstrap/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..fafb61df1309d6450524c18e39367ba9aebbbb4e --- /dev/null +++ b/basetheme_bootstrap/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url +from django.contrib.auth import views as auth_views +from django.contrib.auth.decorators import login_required +from django.views.generic import TemplateView + +from basetheme_bootstrap import views + +app_name = 'basetheme_bootstrap' +urlpatterns = [ + url(r'^accounts/$', views.account_detail, name='account'), + url(r'^accounts/login/$', auth_views.LoginView.as_view(), name='login'), + url(r'^accounts/logout/$', auth_views.LogoutView.as_view(next_page='/'), name='logout'), + url(r'^accounts/password/$', views.change_password, name='change_password'), + url(r'^accounts/signup/$', views.signup, name='signup'), + url(r'^accounts/edit/$', views.user_update, name='user-update'), + url(r'^accounts/remove/$', views.user_delete, name='user-delete'), + url(r'^about/$', views.about_page, name='about_page'), +] diff --git a/basetheme_bootstrap/user_preferences.py b/basetheme_bootstrap/user_preferences.py new file mode 100644 index 0000000000000000000000000000000000000000..0449344cf69329fae0ccce04abaca3969cdea1bb --- /dev/null +++ b/basetheme_bootstrap/user_preferences.py @@ -0,0 +1,94 @@ +import logging + +from django.apps import apps +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext_lazy as _, ugettext + +logger = logging.getLogger("basetheme_bootstrap") + + +def get_user_preference_class(): + if cache.get("has_no_user_preferences_model", False): + return None + if not getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED", False): + cache.set("has_no_user_preferences_model", True, None) + return None + try: + return apps.get_model( + app_label=getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_LOCATION_APP", "your_app"), + model_name=getattr(settings, "BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME", "UserPreferences"), + ) + except LookupError as e: + logging.error("Either create the UserPreferences model, " + "or set BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_ENABLED to False") + cache.set("has_no_user_preferences_model", True, None) + return None + + +def get_user_preferences_for_user(user): + if cache.get("has_no_user_preferences_model", False): + return None + klass = get_user_preference_class() + if klass is None: + return None + return klass.get_for_user(user=user) + + +class UserPreferencesAbstractModel(models.Model): + class Meta: + abstract = True + + user = models.ForeignKey( + get_user_model(), + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="user_preferences", + help_text=_("Leave blank for default user") + ) + + @classmethod + def get_for_user(cls, user): + try: + pref = cls.objects.get(user=user) + except cls.DoesNotExist: + pref, _ = cls.objects.get_or_create(user=None) + if user is not None: + pref.pk = None + pref.id = None + pref.user = user + pref.save() + return pref + + def full_clean(self, **kwargs): + # Checking for coherency on default preferences (user is None) + if self.user is None: + try: + default_pref = self.__class__.objects.get(user=None) + if default_pref.id != self.id: + raise ValidationError('Can\'t have more than one default preferences object, specify a user.') + except self.__class__.DoesNotExist: + pass + + return super().full_clean(**kwargs) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + def __str__(self): + return ugettext("Default preferences") if self.user is None else '%s (%s)' % ( + self.user.username, + self._meta.verbose_name.title(), + ) + + @classmethod + def get_allowed_fields(cls): + for field_name in [f.name for f in cls._meta.get_fields()]: + if field_name == "id" or field_name == "pk" or field_name == "user": + continue + yield field_name diff --git a/basetheme_bootstrap/views.py b/basetheme_bootstrap/views.py new file mode 100644 index 0000000000000000000000000000000000000000..92da03906b35f126e10e57334d3482fecbb91855 --- /dev/null +++ b/basetheme_bootstrap/views.py @@ -0,0 +1,163 @@ +import logging + +from django import forms +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import update_session_auth_hash, authenticate, login, get_user_model +from django.contrib.auth.decorators import login_required +from django.contrib.auth.forms import PasswordChangeForm +from django.core.mail import send_mail +from django.db.models import ProtectedError +from django.forms import widgets +from django.shortcuts import render, redirect +from django.template import TemplateDoesNotExist +from django.utils.translation import ugettext + +from basetheme_bootstrap import user_preferences +from basetheme_bootstrap.forms import UserCreationFormWithMore, \ + MyUserChangeForm, UserDeleteForm + +logger = logging.getLogger(__name__) + + +def about_page(request): + context = {} + try: + return render(request, settings.BASETHEME_BOOTSTRAP_TEMPLATE_LOCATION_PROJECT + '/about.html') + except TemplateDoesNotExist: + return render(request, 'basetheme_bootstrap/about.example.html') + + +@login_required +def change_password(request): + if request.method == 'POST': + form = PasswordChangeForm(request.user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, 'Your password was successfully updated!') + return redirect('basetheme_bootstrap:account') + else: + messages.error(request, 'Please correct the error below.') + else: + form = PasswordChangeForm(request.user) + return render(request, 'registration/small_form_host.html', { + 'title': ugettext('Change password'), + 'submit_text': ugettext('Save changes'), + 'form': form + }) + + +def signup(request): + if request.method == 'POST': + form = UserCreationFormWithMore(request.POST) + if form.is_valid(): + user = form.save() + if get_user_model().objects.filter(pk__gt=1).count() == 0: + user.is_superuser = True + user.is_staff = True + user.save() + username = form.cleaned_data.get('username') + raw_password = form.cleaned_data.get('password1') + user = authenticate(username=username, password=raw_password) + request.user = user + + try: + send_mail( + subject=ugettext('Account successfully created'), + message=ugettext( + 'Dear %(first_name)s %(last_name)s\n\n' + 'Your account have successfully been created on %(joined)s.\n\nBest regards') % dict( + first_name=request.user.first_name, + last_name=request.user.last_name, + joined=str(request.user.date_joined), + ), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[request.user.email], + fail_silently=False, + ) + except Exception as e: + logging.exception("Sending email to user %i failed" % user.pk) + + login(request, user) + return redirect('home') + else: + form = UserCreationFormWithMore() + return render(request, 'registration/signup.html', {'form': form}) + + +def user_update(request): + if request.method == 'POST': + form = MyUserChangeForm(instance=request.user, data=request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) # Important! + messages.success(request, 'Your account was successfully updated!') + return redirect('basetheme_bootstrap:account') + else: + messages.error(request, 'Please correct the error below.') + else: + form = MyUserChangeForm(instance=request.user) + return render(request, 'registration/small_form_host.html', { + 'title': ugettext('Update account'), + 'submit_text': ugettext('Save changes'), + 'form': form, + 'medium_width': True, + }) + + +def user_delete(request): + if request.method == 'POST': + form = UserDeleteForm(instance=request.user, data=request.POST) + if form.is_valid(): + try: + user = form.save() + user.delete() + update_session_auth_hash(request, user) # Important! + messages.success(request, 'Your account was successfully deleted!') + return redirect('/') + except ProtectedError as e: + messages.error(request, ugettext("Some data remaining in the system prevent the deletion of your " + "account. Please either remove these data, or contact the " + "administrator to solve the issue.\nMessage:\n" + str(e))) + except Exception as e: + messages.error(request, e) + else: + messages.error(request, 'Please correct the error below.') + else: + form = UserDeleteForm(instance=request.user) + return render(request, 'registration/small_form_host.html', { + 'title': ugettext('Account deletion'), + 'submit_text': ugettext('Delete account and all related data'), + 'form': form, + 'medium_width': True, + 'btn_classes': 'btn-lg btn-danger text-center', + 'btn_container_classes': 'text-center', + }) + + +def account_detail(request): + klass = user_preferences.get_user_preference_class() + if klass is None: + form_prefs = None + else: + is_posted = request.method == 'POST' + pref = klass.get_for_user(user=request.user) + form_prefs = forms.modelform_factory( + klass, + fields=list(klass.get_allowed_fields()), + )(instance=pref, data=request.POST if is_posted else None) + if is_posted and form_prefs.is_valid(): + form_prefs.save() + form_prefs = forms.modelform_factory( + klass, + fields=list(klass.get_allowed_fields()), + )(instance=klass.get_for_user(user=request.user)) + # dirty patch for time field (don't know why django doesn't express the correct type by default) + for f in form_prefs.fields.values(): + if isinstance(f.widget, widgets.TimeInput): + f.widget.input_type = 'time' + return render(request, 'registration/account.html', { + 'form_prefs': form_prefs, + 'btn_classes': 'pull-right float-right' + }) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..ac6a521f70d05b62702a487781ca619d0214e4a1 --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import glob +from setuptools import setup + +readme = open('README.rst').read() + +setup( + name='django-basetheme-bootstrap', + version='0.0.0', + description='Django Basetheme Bootstrap', + long_description=readme, + author='Bryan Brancotte', + author_email='bryan.brancotte@pasteur.fr', + packages=['basetheme_bootstrap'], + install_requires=[ + 'django', + 'django-crispy-forms' + ], + license="BSD" +)