diff --git a/.gitignore b/.gitignore index 17613c7..4e2f06d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ database/db.sqlite3 geckodriver.log __pycache__ *.pyc -.env/ +.env/ \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 20ed062..da0590c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,38 @@ Changelog ========= +3.0.4 (2023-06-18) +================== + +- fix broken examples by bumping django-widget-tweaks dependency version + +3.0.3 (2023-06-04) +================== + +- add get_success_message method in FormValidationMixin + + +3.0.2 (2023-05-02) +================== + +- fix call to get_success_url method in FormValidationMixin + +3.0.1 (2023-05-02) +================== + +- fix redirect in FormValidationMixin + +3.0.0 (2023-05-02) +================== + +- deprecate support for python < 3.8 +- deprecate support for Django < 3.2 +- deprecate SuccessMessageMixin +- add FormValidationMixin +- add support for Chrome, Firefox, Edge and Safari drivers when running funtional tests +- update examples to Django=4.2 +- update README.rst + 2.2.1 (2023-03-05) ================== diff --git a/README.rst b/README.rst index 8e049d8..ff1d948 100644 --- a/README.rst +++ b/README.rst @@ -4,23 +4,43 @@ Django Bootstrap Modal Forms A Django plugin for creating AJAX driven forms in Bootstrap modal. -Live Demo -========= - -Demo_ - -.. _Demo: http://trco.silkym.com/dbmf/ +.. contents:: **Table of Contents** + :depth: 2 + :local: + :backlinks: none Test and experiment on your machine =================================== -This repository includes ``Dockerfile`` and ``docker-compose.yml`` files so you can easily setup and start to experiment with ``django-bootstrap-modal-forms`` running inside of a container on your local machine. Any changes you make in ``bootstrap_modal_forms``, ``examples`` and ``test`` folders are reflected in the container (see docker-compose.yml) and the data stored in sqlite3 database are persistent even if you remove stopped container. Follow the steps below to run the app:: +This repository includes ``Dockerfile`` and ``docker-compose.yml`` files so you can easily setup and start to experiment with ``django-bootstrap-modal-forms`` running inside of a container on your local machine. Any changes you make in ``bootstrap_modal_forms``, ``examples`` and ``test`` folders are reflected in the container (see docker-compose.yml) and the data stored in sqlite3 database are persistent even if you remove stopped container. + +Note that ``master branch`` contains Bootstrap 4 examples, while ``bootstrap5-examples branch`` contains Bootstrap 5 examples. To experiment with Bootstrap 5 examples simply switch the branch. + +Follow the steps below to run the app:: $ clone repository $ cd django-bootstrap-modal-forms $ docker compose up (use -d flag to run app in detached mode in the background) $ visit 0.0.0.0:8000 +General information +=================== + +Opening an issue +**************** +When reporting an issue for ``django-bootstrap-modal-forms`` package, please prepare a publicly available repository having the issue you are reporting. The clear reproduce is the optimal way towards resolution. + +Contribute +********** +This is an Open Source project and any contribution is highly appreciated. + +Tests +===== + +Run unit and functional tests inside of project folder:: + + $ python manage.py test + Installation ============ @@ -36,9 +56,7 @@ Installation ... ] -3. Include Bootstrap, jQuery and ``jquery.bootstrap.modal.forms.js`` on every page where you would like to set up the AJAX driven Django forms in Bootstrap modal. - -IMPORTANT: Adjust Bootstrap and jQuery file paths to match yours, but include ``jquery.bootstrap.modal.forms.js`` exactly as in code bellow. +3. Include Bootstrap, jQuery and ``jquery.bootstrap(5).modal.forms.js`` on every page where you would like to set up the AJAX driven Django forms in Bootstrap modal. **IMPORTANT:** Adjust Bootstrap and jQuery file paths to match yours, but include ``jquery.bootstrap.modal.forms.js`` exactly as in code bellow. .. code-block:: html+django @@ -50,12 +68,14 @@ IMPORTANT: Adjust Bootstrap and jQuery file paths to match yours, but include `` - - + + + - + + @@ -129,10 +149,10 @@ Define BookModelForm and inherit built-in form ``BSModalModelForm``. Define form's html and save it as Django template. -- Bootstrap 4 modal elements are used in this example. - Form will POST to ``formURL`` defined in #6. -- Add ``class="invalid"`` or custom ``errorClass`` (see paragraph **Options**) to the elements that wrap the fields. +- Add ``class="invalid"`` or custom ``errorClass`` (see paragraph **Options**) to the elements that wrap the fields - ``class="invalid"`` acts as a flag for the fields having errors after the form has been POSTed. +- **IMPORTANT NOTE:** Bootstrap 4 modal elements are used in this example. ``class="invalid"`` is the default for Bootstrap 4. ``class="is-invalid"`` is the default for Bootstrap 5. .. code-block:: html @@ -381,7 +401,7 @@ isDeleteForm Defines if form is used for deletion. Should be set to ``true`` for deletion forms. ``Default: false`` errorClass - Sets the custom class for the form fields having errors. ``Default: ".invalid"`` + Sets the custom class for the form fields having errors. ``Default: ".invalid" for Boostrap 4 and ".is-invalid" for Bootstrap 5.`` asyncUpdate Sets asynchronous content update after form submission. ``Default: false`` @@ -404,6 +424,40 @@ asyncSettings.dataKey asyncSettings.addModalFormFunction Sets the method needed for reinstantiation of event listeners on buttons (single or all CRUD buttons) after asynchronous update. ``Default: null`` +Fetch API parameters +******************** + +(@see `MDN Web DOCs - Using the Fetch API `_). + +credentials: + Choices: omit, include, same-origin + + ``Default: same-origin`` +method: + Choices: GET, POST, PUT, DELETE + + ``Default: POST`` +mode: + Choices: no-cors, cors, same-origin + + ``Default: cors`` +cache: + Choices: default, no-cache, reload, force-cache, only-if-cached + + ``Default: no-cache`` +headers: + Choices: @see `MDN Web DOCs - HTTP headers `_ + + ``Default: { "Content-Type": "application/json" }`` +redirect: + Choices: manual, follow, error + + ``Default: follow`` +referrerPolicy: + Choices: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + + ``Default: no-referrer`` + modalForm default settings object and it's structure **************************************************** @@ -415,6 +469,7 @@ modalForm default settings object and it's structure modalForm: ".modal-content form", formURL: null, isDeleteForm: false, + // ".invalid" is the default for Bootstrap 4. ".is-invalid" is the default for Bootstrap 5. errorClass: ".invalid", asyncUpdate: false, asyncSettings: { @@ -424,7 +479,17 @@ modalForm default settings object and it's structure dataElementId: null, dataKey: null, addModalFormFunction: null - } + }, + // At this point Fetch API parameters + credentials: 'same-origin', + method: 'POST', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json' + }, + redirect: 'follow', + referrerPolicy: 'no-referrer' }); Forms @@ -444,33 +509,39 @@ Mixins Import mixins with ``from bootstrap_modal_forms.mixins import PassRequestMixin``. PassRequestMixin - Puts the request into the form's kwargs. + Form Mixin which puts the request into the form's kwargs. Note: Using this mixin requires you to pop the `request` kwarg out of the dict in the super of your form's `__init__`. See PopRequestMixin. PopRequestMixin - Pops request out of the kwargs and attaches it to the form's instance. + Form Mixin which pops request out of the kwargs and attaches it to the form's instance. Note: This mixin must precede forms.ModelForm/forms.Form. The form is not expecting these kwargs to be passed in, so they must be popped off before anything else is done. CreateUpdateAjaxMixin - Saves or doesn't save the object based on the request type. + ModelForm Mixin which passes or saves object based on request type. DeleteMessageMixin - Deletes object if request is not ajax request. + Generic View Mixin which adds message to BSModalDeleteView and only calls the post method if request is not ajax request. In case request is ajax post method calls delete method, which redirects to success url. + +FormValidationMixin + Generic View Mixin which saves object and redirects to success_url if request is not ajax request. Otherwise response 204 No content is returned. LoginAjaxMixin - Authenticates user if request is not ajax request. + Generic View Mixin which authenticates user if request is not ajax request. Generic views ============= Import generic views with ``from bootstrap_modal_forms.generic import BSModalFormView``. +BSModalLoginView + Inhertis LoginAjaxMixin and Django's LoginView. + BSModalFormView Inherits PassRequestMixin and Django's generic.FormView. BSModalCreateView - Inherits PassRequestMixin and Django's SuccessMessageMixin and generic.CreateView. + Inherits PassRequestMixin, FormValidationMixin and generic.CreateView. BSModalUpdateView - Inherits PassRequestMixin and Django's SuccessMessageMixin and generic.UpdateView. + Inherits PassRequestMixin, FormValidationMixin and generic.UpdateView. BSModalReadView Inherits Django's generic.DetailView. @@ -489,13 +560,6 @@ To see ``django-bootstrap-modal-forms`` in action clone the repository and run t $ python manage.py migrate $ python manage.py runserver -Tests -===== - -Run unit and functional tests inside of project folder:: - - $ python manage.py test - Example 1: Signup form in Bootstrap modal ***************************************** @@ -1115,11 +1179,6 @@ For explanation how all the parts of the code work together see paragraph **Usag }); -Contribute -========== - -This is an Open Source project and any contribution is appreciated. - License ======= diff --git a/bootstrap_modal_forms/compatibility.py b/bootstrap_modal_forms/compatibility.py deleted file mode 100644 index 60b0168..0000000 --- a/bootstrap_modal_forms/compatibility.py +++ /dev/null @@ -1,84 +0,0 @@ -from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model, login as auth_login -from django.contrib.auth.forms import AuthenticationForm -from django.contrib.sites.shortcuts import get_current_site -from django.http import HttpResponseRedirect -from django.shortcuts import resolve_url -from django.utils.decorators import method_decorator -from django.utils.http import is_safe_url -from django.views.decorators.cache import never_cache -from django.views.decorators.csrf import csrf_protect -from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic.edit import FormView - - -class SuccessURLAllowedHostsMixin: - success_url_allowed_hosts = set() - - def get_success_url_allowed_hosts(self): - return {self.request.get_host(), *self.success_url_allowed_hosts} - - -class LoginView(SuccessURLAllowedHostsMixin, FormView): - """ - Display the login form and handle the login action. - """ - form_class = AuthenticationForm - authentication_form = None - redirect_field_name = REDIRECT_FIELD_NAME - template_name = 'registration/login.html' - redirect_authenticated_user = False - extra_context = None - - @method_decorator(sensitive_post_parameters()) - @method_decorator(csrf_protect) - @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): - if self.redirect_authenticated_user and self.request.user.is_authenticated: - redirect_to = self.get_success_url() - if redirect_to == self.request.path: - raise ValueError( - 'Redirection loop for authenticated user detected. Check that ' - 'your LOGIN_REDIRECT_URL doesn\'t point to a login page.' - ) - return HttpResponseRedirect(redirect_to) - return super().dispatch(request, *args, **kwargs) - - def get_success_url(self): - url = self.get_redirect_url() - return url or resolve_url(settings.LOGIN_REDIRECT_URL) - - def get_redirect_url(self): - """Return the user-originating redirect URL if it's safe.""" - redirect_to = self.request.POST.get( - self.redirect_field_name, - self.request.GET.get(self.redirect_field_name, '') - ) - url_is_safe = is_safe_url( - url=redirect_to - ) - return redirect_to if url_is_safe else '' - - def get_form_class(self): - return self.authentication_form or self.form_class - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['request'] = self.request - return kwargs - - def form_valid(self, form): - """Security check complete. Log the user in.""" - auth_login(self.request, form.get_user()) - return HttpResponseRedirect(self.get_success_url()) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - current_site = get_current_site(self.request) - context.update({ - self.redirect_field_name: self.get_redirect_url(), - 'site': current_site, - 'site_name': current_site.name, - **(self.extra_context or {}) - }) - return context \ No newline at end of file diff --git a/bootstrap_modal_forms/generic.py b/bootstrap_modal_forms/generic.py index 3df0873..d341f27 100644 --- a/bootstrap_modal_forms/generic.py +++ b/bootstrap_modal_forms/generic.py @@ -1,20 +1,10 @@ -import django -from django.contrib.messages.views import SuccessMessageMixin from django.views import generic -from .mixins import PassRequestMixin, DeleteMessageMixin, LoginAjaxMixin +from django.contrib.auth.views import LoginView -DJANGO_VERSION = django.get_version().split('.') -DJANGO_MAJOR_VERSION = DJANGO_VERSION[0] -DJANGO_MINOR_VERSION = DJANGO_VERSION[1] +from .mixins import PassRequestMixin, DeleteMessageMixin, LoginAjaxMixin, FormValidationMixin -# Import custom LoginView for Django versions < 1.11 -if DJANGO_MAJOR_VERSION == '1' and '11' not in DJANGO_MINOR_VERSION: - from .compatibility import LoginView -else: - from django.contrib.auth.views import LoginView - -class BSModalLoginView(LoginAjaxMixin, SuccessMessageMixin, LoginView): +class BSModalLoginView(LoginAjaxMixin, LoginView): pass @@ -22,11 +12,11 @@ class BSModalFormView(PassRequestMixin, generic.FormView): pass -class BSModalCreateView(PassRequestMixin, SuccessMessageMixin, generic.CreateView): +class BSModalCreateView(PassRequestMixin, FormValidationMixin, generic.CreateView): pass -class BSModalUpdateView(PassRequestMixin, SuccessMessageMixin, generic.UpdateView): +class BSModalUpdateView(PassRequestMixin, FormValidationMixin, generic.UpdateView): pass diff --git a/bootstrap_modal_forms/mixins.py b/bootstrap_modal_forms/mixins.py index b275538..eeaff5d 100644 --- a/bootstrap_modal_forms/mixins.py +++ b/bootstrap_modal_forms/mixins.py @@ -1,27 +1,24 @@ from django.contrib import messages from django.contrib.auth import login as auth_login -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, HttpResponse -from .utils import is_ajax - - -class PassRequestMixin(object): +class PassRequestMixin: """ - Mixin which puts the request into the form's kwargs. + Form Mixin which puts the request into the form's kwargs. Note: Using this mixin requires you to pop the `request` kwarg out of the dict in the super of your form's `__init__`. """ def get_form_kwargs(self): - kwargs = super(PassRequestMixin, self).get_form_kwargs() - kwargs.update({'request': self.request}) + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request return kwargs -class PopRequestMixin(object): +class PopRequestMixin: """ - Mixin which pops request out of the kwargs and attaches it to the form's + Form Mixin which pops request out of the kwargs and attaches it to the form's instance. Note: This mixin must precede forms.ModelForm/forms.Form. The form is not @@ -29,45 +26,80 @@ class PopRequestMixin(object): anything else is done. """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: self.request = kwargs.pop('request', None) - super(PopRequestMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) -class CreateUpdateAjaxMixin(object): +class CreateUpdateAjaxMixin: """ - Mixin which passes or saves object based on request type. + ModelForm Mixin which passes or saves object based on request type. """ def save(self, commit=True): - if not is_ajax(self.request.META) or self.request.POST.get('asyncUpdate') == 'True': - instance = super(CreateUpdateAjaxMixin, self).save(commit=commit) - else: - instance = super(CreateUpdateAjaxMixin, self).save(commit=False) - return instance + isAjaxRequest = is_ajax(self.request.META) + asyncUpdate = self.request.POST.get('asyncUpdate') == 'True' + if not isAjaxRequest or asyncUpdate: + return super().save(commit=commit) + if isAjaxRequest: + return super().save(commit=False) -class DeleteMessageMixin(object): + +class DeleteMessageMixin: """ - Mixin which adds message to BSModalDeleteView and only calls the delete method if request - is not ajax request. + Generic View Mixin which adds message to BSModalDeleteView and only calls the post method if request + is not ajax request. In case request is ajax post method calls delete method, which redirects to success url. """ - - def delete(self, request, *args, **kwargs): + + def post(self, request, *args, **kwargs): if not is_ajax(request.META): messages.success(request, self.success_message) - return super(DeleteMessageMixin, self).delete(request, *args, **kwargs) + return super().post(request, *args, **kwargs) else: self.object = self.get_object() return HttpResponseRedirect(self.get_success_url()) -class LoginAjaxMixin(object): + +class LoginAjaxMixin: """ - Mixin which authenticates user if request is not ajax request. + Generic View Mixin which authenticates user if request is not ajax request. """ def form_valid(self, form): if not is_ajax(self.request.META): auth_login(self.request, form.get_user()) messages.success(self.request, self.success_message) - return HttpResponseRedirect(self.get_success_url()) \ No newline at end of file + return HttpResponseRedirect(self.get_success_url()) + + +class FormValidationMixin: + """ + Generic View Mixin which saves object and redirects to success_url if request is not ajax request. Otherwise response 204 No content is returned. + """ + + def get_success_message(self): + if hasattr(self, 'success_message'): + return self.success_message + + def get_success_url(self): + if self.success_url: + return self.success_url + return super().get_success_url() + + def form_valid(self, form): + isAjaxRequest = is_ajax(self.request.META) + asyncUpdate = self.request.POST.get('asyncUpdate') == 'True' + + if isAjaxRequest: + if asyncUpdate: + form.save() + return HttpResponse(status=204) + + form.save() + messages.success(self.request, self.get_success_message()) + return HttpResponseRedirect(self.get_success_url()) + + +def is_ajax(meta): + return 'HTTP_X_REQUESTED_WITH' in meta and meta['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' diff --git a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js index 3008b69..5b5e86a 100644 --- a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js +++ b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js @@ -1,10 +1,15 @@ +'use strict'; /* django-bootstrap-modal-forms -version : 2.2.1 +version : 3.0.4 Copyright (c) 2023 Marcel Rupp */ -// Open modal & load the form at formURL to the modalContent element +/** + * Open modal & load the form as inner HTML from "formURL" to the "modalContent" element + * + * @param {Object} settings Configuration/Settings for associated modal + */ const modalFormCallback = function (settings) { let modal = document.querySelector(settings.modalID); let content = modal.querySelector(settings.modalContent); @@ -16,21 +21,32 @@ const modalFormCallback = function (settings) { }) } - fetch(settings.formURL).then(res => { + fetch(settings.formURL, {credentials: settings.credentials, method: settings.method, mode: settings.mode, cache: settings.cache, headers: settings.headers, redirect: settings.redirect, referrerPolicy: settings.referrerPolicy}).then(res => { + // Get content from target URL return res.text(); }).then(data => { + // Set content to inner HTML content.innerHTML = data; }).then(() => { + // Finally show the modal with new content modalInstance.show(); let form = modal.querySelector(settings.modalForm); if (form) { form.action = settings.formURL; - addEventHandlers(modal, form, settings) + // Add handler for form validation + addEventHandlers(modal, form, settings); } }); }; +/** + * Adds event handler for form validation cycle. + * + * @param {HTMLElement} modal The modal + * @param {HTMLElement} form The actual form, that should be evaluated by the server + * @param {Object} settings Configuration/Settings for associated modal + */ const addEventHandlers = function (modal, form, settings) { form.addEventListener('submit', (event) => { if (settings.isDeleteForm === false) { @@ -40,7 +56,7 @@ const addEventHandlers = function (modal, form, settings) { } }); - modal.addEventListener('hidden.bs.modal', (event) => { + modal.addEventListener('hidden.bs.modal', () => { let content = modal.querySelector(settings.modalContent); while (content.lastChild) { content.removeChild(content.lastChild); @@ -48,23 +64,33 @@ const addEventHandlers = function (modal, form, settings) { }); }; -// Check if form.is_valid() & either show errors or submit it via callback +/** + * Sends the form to the server & processes the result. If the form is valid the redirect from the + * form will be executed. If the form is invalid the errors are shown and no redirect will be executed. + * + * @param {Object} settings Configuration/Settings for associated modal + * @param {Function} callback Callback to break out of form validation cycle + */ const isFormValid = function (settings, callback) { let modal = document.querySelector(settings.modalID); let form = modal.querySelector(settings.modalForm); - const headers = new Headers(); + let headers = new Headers(); headers.append('X-Requested-With', 'XMLHttpRequest'); let btnSubmit = modal.querySelector('button[type="submit"]'); btnSubmit.disabled = true; + fetch(form.action, { headers: headers, method: form.method, body: new FormData(form), + credentials: settings.credentials, }).then(res => { return res.text(); }).then(data => { + // console.log(data) if (data.includes(settings.errorClass)) { + // Form is invalid, therefore set the returned form (with marked invalid fields) to new inner HTML modal.querySelector(settings.modalContent).innerHTML = data; form = modal.querySelector(settings.modalForm); @@ -73,6 +99,7 @@ const isFormValid = function (settings, callback) { return; } + // Start from the beginning form.action = settings.formURL; addEventHandlers(modal, form, settings) } else { @@ -81,7 +108,11 @@ const isFormValid = function (settings, callback) { }); }; -// Submit form callback function +/** + * Submit form callback function + * + * @param {Object} settings Configuration/Settings for associated modal + */ const submitForm = function (settings) { let modal = document.querySelector(settings.modalID); let form = modal.querySelector(settings.modalForm); @@ -89,7 +120,7 @@ const submitForm = function (settings) { if (!settings.asyncUpdate) { form.submit(); } else { - let asyncSettingsValid = validateAsyncSettings(settings.asyncSettings); + const asyncSettingsValid = validateAsyncSettings(settings.asyncSettings); if (asyncSettingsValid) { let asyncSettings = settings.asyncSettings; // Serialize form data @@ -100,6 +131,7 @@ const submitForm = function (settings) { fetch(form.action, { method: form.method, body: formData, + credentials: settings.credentials, }).then(res => { return res.text(); }).then(data => { @@ -114,7 +146,7 @@ const submitForm = function (settings) { if (asyncSettings.dataUrl) { // Update page without refresh - fetch(asyncSettings.dataUrl).then(res => res.json()).then(data => { + fetch(asyncSettings.dataUrl, {credentials: settings.credentials}).then(res => res.json()).then(data => { // Update page let dataElement = document.querySelector(asyncSettings.dataElementId); if (dataElement) { @@ -130,7 +162,7 @@ const submitForm = function (settings) { bootstrap.Modal.getInstance(modal).hide(); } else { // Reload form - fetch(settings.formURL).then(res => { + fetch(settings.formURL, {credentials: settings.credentials}).then(res => { return res.text(); }).then(data => { let content = modal.querySelector(settings.modalContent); @@ -155,9 +187,14 @@ const submitForm = function (settings) { } }; +/** + * Validates given settings/configuration for asynchronous calls. + * + * @param {Object} settings Configuration/Settings for associated modal + * @return {boolean} True if given configuration/settings is valid, false otherwise + */ const validateAsyncSettings = function (settings) { - console.log(settings) - var missingSettings = []; + let missingSettings = []; if (!settings.successMessage) { missingSettings.push("successMessage"); @@ -179,23 +216,30 @@ const validateAsyncSettings = function (settings) { missingSettings.push("addModalFormFunction"); console.error("django-bootstrap-modal-forms: 'addModalFormFunction' in asyncSettings is missing."); } - - if (missingSettings.length > 0) { - return false; - } - - return true; + return missingSettings.length < 1; }; -const modalForm = function(elem, options) { - // Default settings +/** + * Adds click listener to given button. If button is clicked, associated + * modal makes a call to given URL("formURL") to load its inner HTML. + * + * credentials: + * Prevent browser to share credentials (Cookies, Authorization headers & TLS client certificates for future + * authentication) secrets with malicious 3rd parties. Defaults to "same-origin". + * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#sending_a_request_with_credentials_included + * + * @param {HTMLElement} trigger_btn Button that triggers the modal to open/close + * @param {Object} settings Configuration/Settings for this given modal + * @return {HTMLElement} The button with an event listener + */ +const modalForm = function (trigger_btn, settings) { let defaults = { - modalID: "#modal", - modalContent: ".modal-content", - modalForm: ".modal-content form", + modalID: '#modal', + modalContent: '.modal-content', + modalForm: '.modal-content form', formURL: null, isDeleteForm: false, - errorClass: "is-invalid", + errorClass: 'is-invalid', asyncUpdate: false, asyncSettings: { closeOnSubmit: false, @@ -204,14 +248,23 @@ const modalForm = function(elem, options) { dataElementId: null, dataKey: null, addModalFormFunction: null - } + }, + credentials: 'same-origin', + method: 'POST', + mode: 'cors', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json' + }, + redirect: 'follow', + referrerPolicy: 'no-referrer' }; - let settings = {...defaults, ...options} + const replenished_settings = {...defaults, ...settings} - elem.addEventListener('click', () => { - modalFormCallback(settings); + trigger_btn.addEventListener('click', () => { + modalFormCallback(replenished_settings); }) - return elem; -} + return trigger_btn; +} \ No newline at end of file diff --git a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.min.js b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.min.js index a54abe5..f0bdf64 100644 --- a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.min.js +++ b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.min.js @@ -1 +1 @@ -const modalFormCallback=function(settings){let modal=document.querySelector(settings.modalID);let content=modal.querySelector(settings.modalContent);let modalInstance=bootstrap.Modal.getInstance(modal);if(modalInstance===null){modalInstance=new bootstrap.Modal(modal,{keyboard:false})}fetch(settings.formURL).then(res=>{return res.text()}).then(data=>{content.innerHTML=data}).then(()=>{modalInstance.show();let form=modal.querySelector(settings.modalForm);if(form){form.action=settings.formURL;addEventHandlers(modal,form,settings)}})};const addEventHandlers=function(modal,form,settings){form.addEventListener("submit",event=>{if(settings.isDeleteForm===false){event.preventDefault();isFormValid(settings,submitForm);return false}});modal.addEventListener("hidden.bs.modal",event=>{let content=modal.querySelector(settings.modalContent);while(content.lastChild){content.removeChild(content.lastChild)}})};const isFormValid=function(settings,callback){let modal=document.querySelector(settings.modalID);let form=modal.querySelector(settings.modalForm);const headers=new Headers;headers.append("X-Requested-With","XMLHttpRequest");let btnSubmit=modal.querySelector('button[type="submit"]');btnSubmit.disabled=true;fetch(form.action,{headers:headers,method:form.method,body:new FormData(form)}).then(res=>{return res.text()}).then(data=>{if(data.includes(settings.errorClass)){modal.querySelector(settings.modalContent).innerHTML=data;form=modal.querySelector(settings.modalForm);if(!form){console.error("no form present in response");return}form.action=settings.formURL;addEventHandlers(modal,form,settings)}else{callback(settings)}})};const submitForm=function(settings){let modal=document.querySelector(settings.modalID);let form=modal.querySelector(settings.modalForm);if(!settings.asyncUpdate){form.submit()}else{let asyncSettingsValid=validateAsyncSettings(settings.asyncSettings);if(asyncSettingsValid){let asyncSettings=settings.asyncSettings;let formData=new FormData(form);formData.append("asyncUpdate","True");fetch(form.action,{method:form.method,body:formData}).then(res=>{return res.text()}).then(data=>{let body=document.body;if(body===undefined){console.error("django-bootstrap-modal-forms: element missing in your html.");return}let doc=(new DOMParser).parseFromString(asyncSettings.successMessage,"text/xml");body.insertBefore(doc.firstChild,body.firstChild);if(asyncSettings.dataUrl){fetch(asyncSettings.dataUrl).then(res=>res.json()).then(data=>{let dataElement=document.querySelector(asyncSettings.dataElementId);if(dataElement){dataElement.innerHTML=data[asyncSettings.dataKey]}if(asyncSettings.addModalFormFunction){asyncSettings.addModalFormFunction()}if(asyncSettings.closeOnSubmit){bootstrap.Modal.getInstance(modal).hide()}else{fetch(settings.formURL).then(res=>{return res.text()}).then(data=>{let content=modal.querySelector(settings.modalContent);content.innerHTML=data;form=modal.querySelector(settings.modalForm);if(!form){console.error("no form present in response");return}form.action=settings.formURL;addEventHandlers(modal,form,settings)})}})}else if(asyncSettings.closeOnSubmit){bootstrap.Modal.getInstance(modal).hide()}})}}};const validateAsyncSettings=function(settings){console.log(settings);var missingSettings=[];if(!settings.successMessage){missingSettings.push("successMessage");console.error("django-bootstrap-modal-forms: 'successMessage' in asyncSettings is missing.")}if(!settings.dataUrl){missingSettings.push("dataUrl");console.error("django-bootstrap-modal-forms: 'dataUrl' in asyncSettings is missing.")}if(!settings.dataElementId){missingSettings.push("dataElementId");console.error("django-bootstrap-modal-forms: 'dataElementId' in asyncSettings is missing.")}if(!settings.dataKey){missingSettings.push("dataKey");console.error("django-bootstrap-modal-forms: 'dataKey' in asyncSettings is missing.")}if(!settings.addModalFormFunction){missingSettings.push("addModalFormFunction");console.error("django-bootstrap-modal-forms: 'addModalFormFunction' in asyncSettings is missing.")}if(missingSettings.length>0){return false}return true};const modalForm=function(elem,options){let defaults={modalID:"#modal",modalContent:".modal-content",modalForm:".modal-content form",formURL:null,isDeleteForm:false,errorClass:"is-invalid",asyncUpdate:false,asyncSettings:{closeOnSubmit:false,successMessage:null,dataUrl:null,dataElementId:null,dataKey:null,addModalFormFunction:null}};let settings={...defaults,...options};elem.addEventListener("click",()=>{modalFormCallback(settings)});return elem}; \ No newline at end of file +const modalFormCallback=function(e){let t=document.querySelector(e.modalID),n=t.querySelector(e.modalContent),o=bootstrap.Modal.getInstance(t);null===o&&(o=new bootstrap.Modal(t,{keyboard:!1})),fetch(e.formURL).then(e=>e.text()).then(e=>{n.innerHTML=e}).then(()=>{o.show();let n=t.querySelector(e.modalForm);n&&(n.setAttribute("action",e.formURL),addEventHandlers(t,n,e))})},addEventHandlers=function(e,t,n){t.addEventListener("submit",e=>{if(!1===n.isDeleteForm)return e.preventDefault(),isFormValid(n,submitForm),!1}),e.addEventListener("hidden.bs.modal",t=>{let o=e.querySelector(n.modalContent);for(;o.lastChild;)o.removeChild(o.lastChild)})},isFormValid=function(e,t){let n=document.querySelector(e.modalID),o=n.querySelector(e.modalForm),r=new Headers;r.append("X-Requested-With","XMLHttpRequest");n.querySelector('button[type="submit"]').disabled=!0,fetch(o.getAttribute("action"),{headers:r,method:o.getAttribute("method"),body:new FormData(o)}).then(e=>e.text()).then(r=>{if(r.includes(e.errorClass)){if(n.querySelector(e.modalContent).innerHTML=r,!(o=n.querySelector(e.modalForm))){console.error("no form present in response");return}o.setAttribute("action",e.formURL),addEventHandlers(n,o,e)}else t(e)})},submitForm=function(e){let t=document.querySelector(e.modalID),n=t.querySelector(e.modalForm);if(e.asyncUpdate){if(validateAsyncSettings(e.asyncSettings)){let o=e.asyncSettings,r=new FormData(n);r.append("asyncUpdate","True"),fetch(n.getAttribute("action"),{method:n.getAttribute("method"),body:r}).then(e=>e.text()).then(r=>{let a=document.body;if(void 0===a){console.error("django-bootstrap-modal-forms: element missing in your html.");return}let s=new DOMParser().parseFromString(o.successMessage,"text/xml");a.insertBefore(s.firstChild,a.firstChild),o.dataUrl?fetch(o.dataUrl).then(e=>e.json()).then(r=>{let a=document.querySelector(o.dataElementId);a&&(a.innerHTML=r[o.dataKey]),o.addModalFormFunction&&o.addModalFormFunction(),o.closeOnSubmit?bootstrap.Modal.getInstance(t).hide():fetch(e.formURL).then(e=>e.text()).then(o=>{if(t.querySelector(e.modalContent).innerHTML=o,!(n=t.querySelector(e.modalForm))){console.error("no form present in response");return}n.setAttribute("action",e.formURL),addEventHandlers(t,n,e)})}):o.closeOnSubmit&&bootstrap.Modal.getInstance(t).hide()})}}else n.submit()},validateAsyncSettings=function(e){var t=[];return e.successMessage||(t.push("successMessage"),console.error("django-bootstrap-modal-forms: 'successMessage' in asyncSettings is missing.")),e.dataUrl||(t.push("dataUrl"),console.error("django-bootstrap-modal-forms: 'dataUrl' in asyncSettings is missing.")),e.dataElementId||(t.push("dataElementId"),console.error("django-bootstrap-modal-forms: 'dataElementId' in asyncSettings is missing.")),e.dataKey||(t.push("dataKey"),console.error("django-bootstrap-modal-forms: 'dataKey' in asyncSettings is missing.")),e.addModalFormFunction||(t.push("addModalFormFunction"),console.error("django-bootstrap-modal-forms: 'addModalFormFunction' in asyncSettings is missing.")),!(t.length>0)},modalForm=function(e,t){let n={modalID:"#modal",modalContent:".modal-content",modalForm:".modal-content form",formURL:null,isDeleteForm:!1,errorClass:"is-invalid",asyncUpdate:!1,asyncSettings:{closeOnSubmit:!1,successMessage:null,dataUrl:null,dataElementId:null,dataKey:null,addModalFormFunction:null},...t};return e.addEventListener("click",()=>{modalFormCallback(n)}),e}; \ No newline at end of file diff --git a/bootstrap_modal_forms/static/js/jquery.bootstrap.modal.forms.js b/bootstrap_modal_forms/static/js/jquery.bootstrap.modal.forms.js index 05f0cda..dca793a 100644 --- a/bootstrap_modal_forms/static/js/jquery.bootstrap.modal.forms.js +++ b/bootstrap_modal_forms/static/js/jquery.bootstrap.modal.forms.js @@ -1,6 +1,6 @@ /* django-bootstrap-modal-forms -version : 2.2.1 +version : 3.0.4 Copyright (c) 2023 Uroš Trstenjak https://github.com/trco/django-bootstrap-modal-forms */ diff --git a/bootstrap_modal_forms/utils.py b/bootstrap_modal_forms/utils.py deleted file mode 100644 index 1519708..0000000 --- a/bootstrap_modal_forms/utils.py +++ /dev/null @@ -1,8 +0,0 @@ -def is_ajax(meta): - if 'HTTP_X_REQUESTED_WITH' not in meta: - return False - - if meta['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest': - return True - - return False \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index fb3ff39..c08ce99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,6 @@ version: '3.9' services: django-bootstrap-modal-forms: - container_name: django-bootstrap-modal-forms build: . command: > sh -c "python manage.py migrate && diff --git a/examples/admin.py b/examples/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/examples/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/examples/apps.py b/examples/apps.py index 5940f52..8ad7c7a 100644 --- a/examples/apps.py +++ b/examples/apps.py @@ -2,4 +2,4 @@ class ExamplesConfig(AppConfig): - name = 'examples' + name = 'examples' \ No newline at end of file diff --git a/examples/forms.py b/examples/forms.py index f0715d8..9d599b2 100644 --- a/examples/forms.py +++ b/examples/forms.py @@ -33,4 +33,4 @@ class Meta: class CustomAuthenticationForm(AuthenticationForm): class Meta: model = User - fields = ['username', 'password'] + fields = ['username', 'password'] \ No newline at end of file diff --git a/examples/migrations/0001_initial.py b/examples/migrations/0001_initial.py index eed1f88..8feaf5f 100644 --- a/examples/migrations/0001_initial.py +++ b/examples/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1 on 2019-03-30 16:15 +# Generated by Django 3.2 on 2023-04-09 15:22 from django.db import migrations, models @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Book', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=50)), ('publication_date', models.DateField(null=True)), ('author', models.CharField(blank=True, max_length=30)), diff --git a/examples/models.py b/examples/models.py index fecc6d7..f17effb 100644 --- a/examples/models.py +++ b/examples/models.py @@ -17,4 +17,4 @@ class Book(models.Model): pages = models.IntegerField(blank=True, null=True) book_type = models.PositiveSmallIntegerField(choices=BOOK_TYPES) - timestamp = models.DateField(auto_now_add=True, auto_now=False) + timestamp = models.DateField(auto_now_add=True, auto_now=False) \ No newline at end of file diff --git a/examples/tests.py b/examples/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/examples/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/examples/urls.py b/examples/urls.py index 8baa565..9e0e1e9 100644 --- a/examples/urls.py +++ b/examples/urls.py @@ -13,4 +13,4 @@ path('books/', views.books, name='books'), path('signup/', views.SignUpView.as_view(), name='signup'), path('login/', views.CustomLoginView.as_view(), name='login'), -] +] \ No newline at end of file diff --git a/examples/views.py b/examples/views.py index 53c6410..0551253 100644 --- a/examples/views.py +++ b/examples/views.py @@ -1,6 +1,5 @@ from django.http import JsonResponse from django.template.loader import render_to_string -from django.contrib.messages.views import SuccessMessageMixin from django.urls import reverse_lazy from django.views import generic @@ -89,7 +88,7 @@ class CustomLoginView(BSModalLoginView): def books(request): - data = dict() + data = {} if request.method == 'GET': books = Book.objects.all() data['table'] = render_to_string( @@ -97,4 +96,4 @@ def books(request): {'books': books}, request=request ) - return JsonResponse(data) + return JsonResponse(data) \ No newline at end of file diff --git a/manage.py b/manage.py index 2c6b4a3..00d0b34 100755 --- a/manage.py +++ b/manage.py @@ -4,12 +4,9 @@ if __name__ == '__main__': os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'setup.settings') + try: from django.core.management import execute_from_command_line except ImportError as exc: - raise ImportError( - 'Couldn\'t import Django. Are you sure it\'s installed and ' - 'available on your PYTHONPATH environment variable? Did you ' - 'forget to activate a virtual environment?' - ) from exc + raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt index e0e9e30..aac648d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -Django==2.2.28 -django-widget-tweaks==1.4.2 -selenium==3.14.0 -pytz==2018.5 +# End of life Django 4.2: April 2026 +# @see https://www.djangoproject.com/download/#supported-versions +Django==4.2.3 +django-widget-tweaks==1.4.12 +selenium==3.14 diff --git a/setup.py b/setup.py index 044863a..c544415 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,19 @@ import os +from pathlib import Path + from setuptools import find_packages, setup -with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: - README = readme.read() +PROJECT_ROOT_DIR = Path(__file__).resolve().parent + +with open(Path(PROJECT_ROOT_DIR, 'README.rst')) as readme_file: + README = readme_file.read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-bootstrap-modal-forms', - version='2.2.1', + version='3.0.4', packages=find_packages(), include_package_data=True, license='MIT License', @@ -19,7 +23,7 @@ author='Uros Trstenjak', author_email='uros.trstenjak@gmail.com', install_requires=[ - 'Django>=1.8', + 'Django>=3.2', ], classifiers=[ 'Development Status :: 5 - Production/Stable', @@ -27,15 +31,10 @@ 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Software Development :: Libraries :: Python Modules' ], ) diff --git a/setup/settings.py b/setup/settings.py index 35172a0..65963cb 100644 --- a/setup/settings.py +++ b/setup/settings.py @@ -1,7 +1,7 @@ import os +from pathlib import Path - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'ke2rim3a=ukld9cjh6$d$fb%ztgobvrs807i^d!_whg%@n^%v#' DEBUG = True @@ -32,12 +32,9 @@ ROOT_URLCONF = 'setup.urls' -PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) - TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'examples/templates'), ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -55,37 +52,27 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'database/db.sqlite3'), + 'NAME': str(Path(BASE_DIR, 'db.sqlite3')), } } -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# No password rules in development +AUTH_PASSWORD_VALIDATORS = [] +# Simple (and unsecure) but fast password hasher in development +PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' -USE_I18N = True - -USE_L10N = True - -USE_TZ = True +# No internationalization for this project +USE_I18N = False +USE_L10N = False +USE_TZ = False STATICFILES_FINDERS = [ # searches in STATICFILES_DIRS @@ -95,7 +82,3 @@ ] STATIC_URL = '/static/' - -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'static'), -] diff --git a/setup/wsgi.py b/setup/wsgi.py index 1ea09b8..0b84e9e 100644 --- a/setup/wsgi.py +++ b/setup/wsgi.py @@ -1,12 +1,3 @@ -""" -WSGI config for setup project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ -""" - import os from django.core.wsgi import get_wsgi_application diff --git a/tests/base.py b/tests/base.py index 85a05c0..50d4393 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,23 +1,83 @@ +from pathlib import Path + from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from setup import settings + from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait -MAX_WAIT = 10 - class FunctionalTest(StaticLiveServerTestCase): + """ + Download your driver of choice, copy & paste it into the root directory of this project and change + the `BROWSER_DRIVER_PATH` variable to your downloaded driver file. + + FireFox + - Driver Download: https://github.com/mozilla/geckodriver/releases + - Compatibility: https://firefox-source-docs.mozilla.org/testing/geckodriver/Support.html + Chrome + - Driver Download: https://chromedriver.chromium.org/downloads + - Compatibility: https://chromedriver.chromium.org/downloads/version-selection + Edge (May also work with preinstalled version. Just try it. If it works, you're good. If not, download the files.) + - Driver Download: https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/ + - Compatibility: https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/ + Safari (May also work with preinstalled version. Just try it. If it works, you're good. If not, download the files.) + - Driver Download: https://developer.apple.com/documentation/webkit/testing_with_webdriver_in_safari + - Compatibility: https://developer.apple.com/documentation/webkit/testing_with_webdriver_in_safari + """ + + BROWSER = None + # Change this, to your browser type of choice + BROWSER_TYPE = webdriver.Chrome + # Change this, to your driver file of your chosen browser + BROWSER_DRIVER_PATH: Path = Path(settings.BASE_DIR, 'chromedriver') + # If you're using Firefox, and you have installed firefox in a none-standard directory, change this to the executable wherever + # you have installed Firefox. E.g.: Path('C:/My/None/Standard/directory/firefox.exe') + FIRE_FOX_BINARY = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.BROWSER = cls.get_browser() + # cls.BROWSER.implicitly_wait(5) - # Basic setUp & tearDown - def setUp(self): - self.browser = webdriver.Firefox() + @classmethod + def tearDownClass(cls): + cls.BROWSER.quit() + super().tearDownClass() - def tearDown(self): - self.browser.quit() + @classmethod + def get_browser(cls): + if cls.BROWSER_TYPE is webdriver.Firefox: + if cls.BROWSER_DRIVER_PATH is None: + raise ValueError('Firefox needs a path to a browser driver file!') + else: + if cls.FIRE_FOX_BINARY is None: + return webdriver.Firefox(executable_path=cls.BROWSER_DRIVER_PATH) + else: + return webdriver.Firefox(firefox_binary=str(cls.FIRE_FOX_BINARY), executable_path=cls.BROWSER_DRIVER_PATH) + elif cls.BROWSER_TYPE is webdriver.Chrome: + if cls.BROWSER_DRIVER_PATH is None: + raise ValueError('Chrome needs a path to a browser driver file!') + else: + return webdriver.Chrome(executable_path=cls.BROWSER_DRIVER_PATH) + elif cls.BROWSER_TYPE is webdriver.Edge: + if cls.BROWSER_DRIVER_PATH is None: + return webdriver.Edge() + else: + return webdriver.Edge(executable_path=cls.BROWSER_DRIVER_PATH) + elif cls.BROWSER_TYPE is webdriver.Safari: + if cls.BROWSER_DRIVER_PATH is None: + return webdriver.Safari() + else: + return webdriver.Safari(executable_path=cls.BROWSER_DRIVER_PATH) + else: + raise RuntimeError(f'Unsupported browser type: {cls.BROWSER_TYPE}') def wait_for(self, class_name=None, element_id=None, tag=None, xpath=None): - return WebDriverWait(self.browser, 20).until( + return WebDriverWait(self.BROWSER, 20).until( expected_conditions.element_to_be_clickable ((By.ID, element_id) if element_id else (By.CLASS_NAME, class_name) if class_name else diff --git a/tests/tests_functional.py b/tests/tests_functional.py index 8c4e321..f56b00d 100644 --- a/tests/tests_functional.py +++ b/tests/tests_functional.py @@ -7,13 +7,13 @@ class SignUpLoginTest(FunctionalTest): def test_signup_login(self): # User visits homepage and checks the content - self.browser.get(self.live_server_url) - self.assertIn('django-bootstrap-modal-forms', self.browser.title) - header_text = self.browser.find_element_by_tag_name('h1').text + self.BROWSER.get(self.live_server_url) + self.assertIn('django-bootstrap-modal-forms', self.BROWSER.title) + header_text = self.BROWSER.find_element_by_tag_name('h1').text self.assertIn('django-bootstrap-modal-forms', header_text) # User clicks Sign up button - self.browser.find_element_by_id('signup-btn').click() + self.BROWSER.find_element_by_id('signup-btn').click() # Sign up modal opens modal = self.wait_for(element_id='modal') @@ -41,14 +41,14 @@ def test_signup_login(self): form.submit() # User sees success message after page redirection - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/') # Slice removes '\nx' since alert is dismissible and contains 'times' button success_msg = self.wait_for(class_name='alert').text[:-2] self.assertEqual(success_msg, 'Success: Sign up succeeded. You can now Log in.') # User clicks log in button - self.browser.find_element_by_id('login-btn').click() + self.BROWSER.find_element_by_id('login-btn').click() # Log in modal opens modal = self.wait_for(element_id='modal') @@ -80,7 +80,7 @@ def test_signup_login(self): # User sees log out button after page redirection logout_btn_txt = self.wait_for(element_id='logout-btn').text self.assertEqual(logout_btn_txt, 'Log out') - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/') @@ -98,10 +98,10 @@ def setUp(self): def test_create_object(self): # User visits homepage - self.browser.get(self.live_server_url) + self.BROWSER.get(self.live_server_url) # User clicks create book button - self.browser.find_element_by_id('create-book-sync').click() + self.BROWSER.find_element_by_id('create-book-sync').click() # Create book modal opens modal = self.wait_for(element_id='create-modal') @@ -140,7 +140,7 @@ def test_create_object(self): form.submit() # User sees success message after page redirection - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/') # Slice removes '\nx' since alert is dismissible and contains 'times' button @@ -160,7 +160,7 @@ def test_create_object(self): def test_filter_object(self): # User visits homepage - self.browser.get(self.live_server_url) + self.BROWSER.get(self.live_server_url) # User clicks filter book button self.wait_for(element_id='filter-book').click() @@ -171,7 +171,7 @@ def test_filter_object(self): # User changes book type form = modal.find_element_by_tag_name('form') - book_type = self.browser.find_element_by_id("id_type") + book_type = self.BROWSER.find_element_by_id("id_type") book_type_select = Select(book_type) book_type_select.select_by_index(0) @@ -179,15 +179,15 @@ def test_filter_object(self): # User is redirected to the homepage with a querystring with the filter self.wait_for(class_name='filtered-books') - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/?type=1$') def test_update_object(self): # User visits homepage - self.browser.get(self.live_server_url) + self.BROWSER.get(self.live_server_url) # User clicks update book button - self.browser.find_element_by_class_name('update-book').click() + self.BROWSER.find_element_by_class_name('update-book').click() # Update book modal opens modal = self.wait_for(element_id='modal') @@ -206,7 +206,7 @@ def test_update_object(self): form.submit() # User sees success message after page redirection - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/') # Slice removes '\nx' since alert is dismissible and contains 'times' button @@ -234,10 +234,10 @@ def test_update_object(self): def test_read_object(self): # User visits homepage - self.browser.get(self.live_server_url) + self.BROWSER.get(self.live_server_url) # User clicks Read book button - self.browser.find_element_by_class_name('read-book').click() + self.BROWSER.find_element_by_class_name('read-book').click() # Read book modal opens modal = self.wait_for(element_id='modal') @@ -254,10 +254,10 @@ def test_read_object(self): def test_delete_object(self): # User visits homepage - self.browser.get(self.live_server_url) + self.BROWSER.get(self.live_server_url) # User clicks Delete book button - self.browser.find_element_by_class_name('delete-book').click() + self.BROWSER.find_element_by_class_name('delete-book').click() # Delete book modal opens modal = self.wait_for(element_id='modal') @@ -271,7 +271,7 @@ def test_delete_object(self): delete_btn.click() # User sees success message after page redirection - redirect_url = self.browser.current_url + redirect_url = self.BROWSER.current_url self.assertRegex(redirect_url, '/') # Slice removes '\nx' since alert is dismissible and contains 'times' button