diff --git a/.gitignore b/.gitignore index 17613c7..7287195 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,358 @@ -database/db.sqlite3 -geckodriver.log -__pycache__ -*.pyc -.env/ +################### +##### Windows ##### +################### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +################# +##### Linux ##### +################# +*~ + +# Temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +################# +##### macOS ##### +################# +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +#################### +##### IntelliJ ##### +#################### +.idea/ +out/ + +# Run configurations +.run/ + +# CMake +cmake-build-*/ + +# File-based project format +*.iws + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### Intellij Patch ### +# @see https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +*.iml +# *.ipr +# modules.xml + +################### +##### Eclipse ##### +################### +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ +.apt_generated_test/ + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# Uncomment this line if you wish to ignore the project description file. Typically, this file would be tracked if +# it would contain build/dependency configurations +#.project + +### Eclipse Patch ### +# Spring Boot Tooling +.sts4-cache/ + +############################ +##### VisualStudioCode ##### +############################ +.vscode/ +.history/ +*.code-workspace + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +################## +##### Python ##### +################## +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template before PyInstaller builds the exe, +# so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.pot + +# Django +*.log +local_settings.py +*.sqlite3 +*.sqlite3-journal + +# Flask +instance/ +.webassets-cache + +# Scrapy +.scrapy + +# Sphinx +doc/build/ +doc/source/_autosummary/* + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is intended to run in multiple +# environments; otherwise, check them in +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. However, in case of +# collaboration, if having platform-specific dependencies or dependencies having no cross-platform support, pipenv may +# install dependencies that don't work, or not install all needed dependencies. +# Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. This is especially +# recommended for binary packages to ensure reproducibility, and is more commonly ignored for libraries. +# @see https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock + +# pdm +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it in version control. +# @see https://pdm.fming.dev/#use-with-ide +.pdm.toml +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### Python Patch ### +# Poetry local configuration file +# @see https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +############################################ +##### django-bootstrap-modal-forms ##### +############################################ +geckodriver.exe diff --git a/bootstrap_modal_forms/compatibility.py b/bootstrap_modal_forms/compatibility.py index 60b0168..ee50f6e 100644 --- a/bootstrap_modal_forms/compatibility.py +++ b/bootstrap_modal_forms/compatibility.py @@ -1,25 +1,24 @@ +from typing import Dict, Any, Set, Type + from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model, login as auth_login +from django.contrib.auth import REDIRECT_FIELD_NAME, 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.http.request import HttpRequest +from django.http.response import HttpResponse from django.shortcuts import resolve_url from django.utils.decorators import method_decorator -from django.utils.http import is_safe_url +from django.utils.http import url_has_allowed_host_and_scheme 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} +from bootstrap_modal_forms.mixins import AuthForm -class LoginView(SuccessURLAllowedHostsMixin, FormView): +class LoginView(FormView): """ Display the login form and handle the login action. """ @@ -29,50 +28,53 @@ class LoginView(SuccessURLAllowedHostsMixin, FormView): template_name = 'registration/login.html' redirect_authenticated_user = False extra_context = None + success_url_allowed_hosts = set() @method_decorator(sensitive_post_parameters()) @method_decorator(csrf_protect) @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: 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.' + "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): + def get_success_url(self) -> str: url = self.get_redirect_url() return url or resolve_url(settings.LOGIN_REDIRECT_URL) - def get_redirect_url(self): + def get_redirect_url(self) -> str: """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 + url_is_safe = url_has_allowed_host_and_scheme( + redirect_to, settings.ALLOWED_HOSTS ) return redirect_to if url_is_safe else '' - def get_form_class(self): + def get_form_class(self) -> Type[AuthForm]: return self.authentication_form or self.form_class - def get_form_kwargs(self): + def get_form_kwargs(self) -> Dict[str, Any]: kwargs = super().get_form_kwargs() kwargs['request'] = self.request return kwargs - def form_valid(self, form): + def form_valid(self, form) -> HttpResponse: """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): + def get_success_url_allowed_hosts(self) -> Set[str]: + return {self.request.get_host(), *self.success_url_allowed_hosts} + + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) current_site = get_current_site(self.request) context.update({ @@ -81,4 +83,4 @@ def get_context_data(self, **kwargs): 'site_name': current_site.name, **(self.extra_context or {}) }) - return context \ No newline at end of file + return context diff --git a/bootstrap_modal_forms/generic.py b/bootstrap_modal_forms/generic.py index 3df0873..2109f36 100644 --- a/bootstrap_modal_forms/generic.py +++ b/bootstrap_modal_forms/generic.py @@ -1,17 +1,8 @@ -import django +from django.contrib.auth.views import LoginView from django.contrib.messages.views import SuccessMessageMixin from django.views import generic -from .mixins import PassRequestMixin, DeleteMessageMixin, LoginAjaxMixin - -DJANGO_VERSION = django.get_version().split('.') -DJANGO_MAJOR_VERSION = DJANGO_VERSION[0] -DJANGO_MINOR_VERSION = DJANGO_VERSION[1] -# 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 +from .mixins import PassRequestMixin, DeleteMessageMixin, LoginAjaxMixin class BSModalLoginView(LoginAjaxMixin, SuccessMessageMixin, LoginView): diff --git a/bootstrap_modal_forms/mixins.py b/bootstrap_modal_forms/mixins.py index b275538..9da3887 100644 --- a/bootstrap_modal_forms/mixins.py +++ b/bootstrap_modal_forms/mixins.py @@ -1,11 +1,19 @@ +from typing import TypeVar, Any + from django.contrib import messages from django.contrib.auth import login as auth_login +from django.contrib.auth.forms import AuthenticationForm +from django.db.models import Model from django.http import HttpResponseRedirect +from django.http.request import HttpRequest + +from .utils import * -from .utils import is_ajax +AuthForm = TypeVar('AuthForm', bound=AuthenticationForm) +DjangoModel = TypeVar('DjangoModel', bound=Model) -class PassRequestMixin(object): +class PassRequestMixin: """ Mixin which puts the request into the form's kwargs. @@ -13,13 +21,13 @@ class PassRequestMixin(object): 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}) + def get_form_kwargs(self: DjangoView) -> Any: + 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 instance. @@ -29,45 +37,45 @@ 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. """ - def save(self, commit=True): + def save(self: DjangoView, commit: bool = True) -> DjangoModel: if not is_ajax(self.request.META) or self.request.POST.get('asyncUpdate') == 'True': - instance = super(CreateUpdateAjaxMixin, self).save(commit=commit) + return super().save(commit=commit) else: - instance = super(CreateUpdateAjaxMixin, self).save(commit=False) - return instance + 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. """ - def delete(self, request, *args, **kwargs): + def delete(self: DeleteMessageMixinProtocol, request: HttpRequest, *args, **kwargs) -> HttpResponseRedirect: if not is_ajax(request.META): messages.success(request, self.success_message) - return super(DeleteMessageMixin, self).delete(request, *args, **kwargs) + return super().delete(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. """ - def form_valid(self, form): + def form_valid(self: LoginAjaxMixinProtocol, form: AuthForm) -> HttpResponseRedirect: 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()) diff --git a/bootstrap_modal_forms/utils.py b/bootstrap_modal_forms/utils.py index 1519708..4882268 100644 --- a/bootstrap_modal_forms/utils.py +++ b/bootstrap_modal_forms/utils.py @@ -1,8 +1,55 @@ -def is_ajax(meta): +__all__ = ('is_ajax', 'DjangoView', 'LoginAjaxMixinProtocol', 'DeleteMessageMixinProtocol',) + +from typing import Protocol, Any, Dict + +from django.http import HttpRequest + + +def is_ajax(meta: Dict): 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 + return False + + +class DjangoView(Protocol): + """ + This is a pure supporting, type hinting class, for mixins that require a HttpRequest attribute. + + @see https://docs.python.org/3/library/typing.html#typing.Protocol + """ + + @property + def request(self) -> HttpRequest: + ... + + +class LoginAjaxMixinProtocol(DjangoView, Protocol): + """ + This is a pure supporting, type hinting class, for mixins that require a success_message + attribute and a get_success_url(...) method. + + @see https://docs.python.org/3/library/typing.html#typing.Protocol + """ + + @property + def success_message(self) -> str: + ... + + def get_success_url(self) -> str: + ... + + +class DeleteMessageMixinProtocol(LoginAjaxMixinProtocol, Protocol): + """ + This is a pure supporting, type hinting class, for mixins that require a success_message attribute, + a get_success_url(...) and a get_object(...) method. + + @see https://docs.python.org/3/library/typing.html#typing.Protocol + """ + + def get_object(self) -> Any: + ... 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..f17dc66 100644 --- a/examples/apps.py +++ b/examples/apps.py @@ -1,5 +1,37 @@ +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import Set + from django.apps import AppConfig +@dataclass(frozen=True) +class ExampleBook: + title: str + publication_date: datetime + author: str + price: float + pages: int + book_type: int # REMEMBER: 1 = 'Hardcover', 2 = 'Paperback', 3 = 'E-book' + + +def get_example_books() -> Set[ExampleBook]: + return { + ExampleBook('Lord of the Rings - 3-Book Paperback Box Set', datetime(year=1954, month=7, day=29), 'J.R.R. Tolkien', 19.99, 1536, 2), + ExampleBook('Lord of the Flies - Large Print Edition', datetime(year=1954, month=9, day=17), 'William Golding', 25.95, 286, 2), + } + + class ExamplesConfig(AppConfig): name = 'examples' + + def ready(self) -> None: + # ATTENTION: Leave the imports here!!! + from examples.models import Book + # Pushing during any migration operation, is not that clever... + if not any(arg in ('makemigrations', 'migrate') for arg in sys.argv): + # Push some examples to play around, if DB is empty... + if Book.objects.all().count() < 1: + books = [Book(title=book.title, publication_date=book.publication_date, author=book.author, price=book.price, pages=book.pages, book_type=book.book_type) for book in get_example_books()] + Book.objects.bulk_create(books) 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..d7f2373 100644 --- a/examples/models.py +++ b/examples/models.py @@ -2,14 +2,12 @@ class Book(models.Model): - HARDCOVER = 1 - PAPERBACK = 2 - EBOOK = 3 BOOK_TYPES = ( - (HARDCOVER, 'Hardcover'), - (PAPERBACK, 'Paperback'), - (EBOOK, 'E-book'), + (1, 'Hardcover'), + (2, 'Paperback'), + (3, 'E-book'), ) + title = models.CharField(max_length=50) publication_date = models.DateField(null=True) author = models.CharField(max_length=30, blank=True) 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/views.py b/examples/views.py index 53c6410..3da7ac1 100644 --- a/examples/views.py +++ b/examples/views.py @@ -1,6 +1,8 @@ +from django.db.models import QuerySet from django.http import JsonResponse +from django.http.request import HttpRequest +from django.http.response import HttpResponse 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 @@ -12,7 +14,6 @@ BSModalReadView, BSModalDeleteView ) - from .forms import ( BookModelForm, CustomUserCreationForm, @@ -27,7 +28,7 @@ class Index(generic.ListView): context_object_name = 'books' template_name = 'index.html' - def get_queryset(self): + def get_queryset(self) -> QuerySet: qs = super().get_queryset() if 'type' in self.request.GET: qs = qs.filter(book_type=int(self.request.GET['type'])) @@ -38,12 +39,12 @@ class BookFilterView(BSModalFormView): template_name = 'examples/filter_book.html' form_class = BookFilterForm - def form_valid(self, form): + def form_valid(self, form) -> HttpResponse: self.filter = '?type=' + form.cleaned_data['type'] response = super().form_valid(form) return response - def get_success_url(self): + def get_success_url(self) -> str: return reverse_lazy('index') + self.filter @@ -88,8 +89,8 @@ class CustomLoginView(BSModalLoginView): success_url = reverse_lazy('index') -def books(request): - data = dict() +def books(request: HttpRequest) -> HttpResponse: + data = {} if request.method == 'GET': books = Book.objects.all() data['table'] = render_to_string( 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..fd5e935 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +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))) +# Python version is given implicitly by Django version. If Django 3.2 requires Python 3.6, this project automatically also needs Python 3.6. +# Django version is defined in "requirements.txt" +# Right now, we need Python 3.8. Because in Python 3.8 type hints with Protocols were introduced. setup( name='django-bootstrap-modal-forms', version='2.2.1', @@ -19,7 +26,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 +34,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/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/base.py b/tests/base.py index 85a05c0..973b9c7 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,23 +1,86 @@ +from pathlib import Path +from typing import Optional, Union, Type + from django.contrib.staticfiles.testing import StaticLiveServerTestCase 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 +from setup import settings + +WebDriver = Union[webdriver.Firefox, webdriver.Chrome, webdriver.Edge, webdriver.Safari] 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: Optional[WebDriver] = None + # Change this, to your browser type of choice + BROWSER_TYPE: Type[WebDriver] = webdriver.Firefox + # Change this, to your driver file of your chosen browser + BROWSER_DRIVER_PATH: Path = Path(settings.BASE_DIR, 'geckodriver.exe') + # 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: Optional[Path] = 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) -> WebDriver: + 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