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/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..37dab68 100644 --- a/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js +++ b/bootstrap_modal_forms/static/js/bootstrap5.modal.forms.js @@ -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/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/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..68c7651 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 3.2: April 2024 +# @see https://www.djangoproject.com/download/#supported-versions +Django==3.2 +django-widget-tweaks~=1.4 +selenium~=3.14 diff --git a/setup.py b/setup.py index 044863a..5c95dc9 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,12 @@ 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))) @@ -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..7124919 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 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