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..e4a9911 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,17 @@ Changelog ========= +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..b82dc2d 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,13 @@ This repository includes ``Dockerfile`` and ``docker-compose.yml`` files so you $ docker compose up (use -d flag to run app in detached mode in the background) $ visit 0.0.0.0:8000 +Tests +===== + +Run unit and functional tests inside of project folder:: + + $ python manage.py test + Installation ============ @@ -36,9 +43,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 +55,14 @@ IMPORTANT: Adjust Bootstrap and jQuery file paths to match yours, but include `` - - + + + - + + @@ -444,33 +451,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 +502,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 ***************************************** 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..b984932 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,71 @@ 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 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.success_message) + return HttpResponseRedirect(self.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..2503f7b 100644 --- a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js +++ b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js @@ -1,6 +1,6 @@ /* django-bootstrap-modal-forms -version : 2.2.1 +version : 3.0.0 Copyright (c) 2023 Marcel Rupp */ @@ -25,7 +25,7 @@ const modalFormCallback = function (settings) { let form = modal.querySelector(settings.modalForm); if (form) { - form.action = settings.formURL; + form.setAttribute("action", settings.formURL); addEventHandlers(modal, form, settings) } }); @@ -57,9 +57,9 @@ const isFormValid = function (settings, callback) { let btnSubmit = modal.querySelector('button[type="submit"]'); btnSubmit.disabled = true; - fetch(form.action, { + fetch(form.getAttribute("action"), { headers: headers, - method: form.method, + method: form.getAttribute("method"), body: new FormData(form), }).then(res => { return res.text(); @@ -73,7 +73,7 @@ const isFormValid = function (settings, callback) { return; } - form.action = settings.formURL; + form.setAttribute("action", settings.formURL); addEventHandlers(modal, form, settings) } else { callback(settings); @@ -97,8 +97,8 @@ const submitForm = function (settings) { // Add asyncUpdate and check for it in save method of CreateUpdateAjaxMixin formData.append("asyncUpdate", "True"); - fetch(form.action, { - method: form.method, + fetch(form.getAttribute("action"), { + method: form.getAttribute("method"), body: formData, }).then(res => { return res.text(); @@ -142,7 +142,7 @@ const submitForm = function (settings) { return; } - form.action = settings.formURL; + form.setAttribute("action", settings.formURL); addEventHandlers(modal, form, settings) }); } @@ -156,7 +156,6 @@ const submitForm = function (settings) { }; const validateAsyncSettings = function (settings) { - console.log(settings) var missingSettings = []; if (!settings.successMessage) { 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..c4cf6bf 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.0 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..ec2cb56 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 +django-widget-tweaks==1.4 +selenium==3.14 diff --git a/setup.py b/setup.py index 044863a..5f64d65 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.0', 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