├── requirements.txt ├── single_session ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── locale │ └── nl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── apps.py ├── admin.py ├── models.py ├── signals.py └── tests.py ├── setup.py ├── MANIFEST.in ├── docs ├── source │ ├── api_admin.rst │ ├── api_apps.rst │ ├── api_models.rst │ ├── api_signals.rst │ ├── admin_actions.rst │ ├── index.rst │ ├── conf.py │ ├── getting_started.rst │ ├── installation.rst │ └── settings.py ├── Makefile └── make.bat ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── django-single-session-ci.yml ├── pyproject.toml ├── setup.cfg ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | django >= 3.0.0 2 | -------------------------------------------------------------------------------- /single_session/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /single_session/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2" 2 | __author__ = "Willem Van Onsem" 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include single_session/locale/ **.po **.mo 4 | -------------------------------------------------------------------------------- /docs/source/api_admin.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Admin 3 | ===== 4 | 5 | .. automodule:: single_session.admin 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/source/api_apps.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Apps 3 | ===== 4 | 5 | .. automodule:: single_session.apps 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/source/api_models.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Models 3 | ====== 4 | 5 | .. automodule:: single_session.models 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/source/api_signals.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Signals 3 | ======= 4 | 5 | .. automodule:: single_session.signals 6 | :members: 7 | -------------------------------------------------------------------------------- /single_session/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hapytex/django-single-session/HEAD/single_session/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | *.sw[po] 5 | *.sqlite 6 | *.sqlite3 7 | 8 | docs/_build 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | .tox/ 13 | .idea/ 14 | *.python-version 15 | .coverage 16 | _version.py 17 | 18 | djutils/ 19 | manage.py 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/articles/displaying-a-sponsor-button-in-your-repository 2 | custom: https://www.buymeacoffee.com/hapytex 3 | # https://help.github.com/en/articles/displaying-a-sponsor-button-in-your-repository 4 | custom: https://www.buymeacoffee.com/hapytex 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-single-session" 3 | version = "0.1.1" 4 | authors = [{name = "Willem Van Onsem", email = "hapythexeu+gh@gmail.com"}] 5 | # The following seems to be defined outside of pyproject.toml 6 | dynamic = ['description', 'readme', 'requires-python', 'license'] 7 | 8 | [build-system] 9 | requires = ['setuptools>=45', 'wheel', 'setuptools_scm[toml]>=6.2'] 10 | build-backend = 'setuptools.build_meta:__legacy__' 11 | 12 | [tool.setuptools_scm] 13 | write_to = "_version.py" 14 | 15 | [tool.black] 16 | extend-exclude = '(.*/migrations/.*|setup)\.py' 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/admin_actions.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Admin actions 3 | ============= 4 | 5 | If one uses the admin site(s), and there is a `ModelAdmin` for the user model, then the package adds two extra actions to that admin. 6 | These actions can log out (normal) users, and all (including admin) users. 7 | 8 | For users to work with these actions, they should be a super user (administrator), or have the `single_session.logout` and `single_session.logout_all` 9 | permissions respectively. We strongly advise *not* to give a user the `single_session.logout_all` permission, since that would mean 10 | that that user can log out administrator users, and by keeping these logged out, thus prevent administrators to do their job properly. 11 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Django single session 3 | ===================== 4 | 5 | django-single-session is a Django library for ensuring that a user can be logged 6 | in to only one session at a time. 7 | 8 | **Features:** 9 | 10 | * ensure that a user is logged in at at most one session/browser/device 11 | 12 | * ensure that a user logs out from all sessions if they log out 13 | 14 | * two actions for the `ModelAdmin` to log out (admin) users 15 | 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: User Guide 20 | 21 | installation 22 | getting_started 23 | admin_actions 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :caption: API documentation 28 | 29 | api_models 30 | api_signals 31 | api_admin 32 | api_apps 33 | 34 | 35 | .. _`tablib`: https://github.com/jazzband/tablib 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | # -- Project information 4 | 5 | project = "django-single-session" 6 | copyright = "2022, Willem Van Onsem" 7 | author = "Willem Van Onsem" 8 | 9 | release = "0.2" 10 | version = "0.2.0" 11 | 12 | from os import environ 13 | from os.path import dirname 14 | from sys import path 15 | 16 | path.insert(0, dirname(dirname(dirname(__file__)))) 17 | environ.setdefault("DJANGO_SETTINGS_MODULE", "docs.source.settings") 18 | 19 | import django 20 | 21 | django.setup() 22 | 23 | # -- General configuration 24 | 25 | extensions = [ 26 | "sphinx.ext.duration", 27 | "sphinx.ext.doctest", 28 | "sphinx.ext.autodoc", 29 | "sphinx.ext.autosummary", 30 | "sphinx.ext.intersphinx", 31 | ] 32 | 33 | intersphinx_mapping = { 34 | "python": ("https://docs.python.org/3/", None), 35 | "sphinx": ("https://www.sphinx-doc.org/en/master/", None), 36 | } 37 | intersphinx_disabled_domains = ["std"] 38 | 39 | templates_path = ["_templates"] 40 | 41 | # -- Options for HTML output 42 | 43 | # html_theme = 'sphinx_rtd_theme' 44 | 45 | # -- Options for EPUB output 46 | epub_show_urls = "footnote" 47 | -------------------------------------------------------------------------------- /single_session/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.8 on 2022-07-27 22:40 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('sessions', '0001_initial'), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='UserSession', 20 | fields=[ 21 | ('session', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='usersessions', related_query_name='usersession', serialize=False, to='sessions.session')), 22 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), 23 | ], 24 | options={ 25 | 'permissions': [('logout', 'Logout user sessions in bulk for a given user.'), ('logout_all', 'Logout user sessions in bulk for a given (admin) user.')], 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-single-session 3 | version= file: _version.py 4 | description = A Django app to enforce users to work only on one browser/device. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/hapytex/django-single-session/ 8 | author = Willem Van Onsem 9 | author_email = hapytexeu+gh@gmail.com 10 | license = BSD-3-Clause 11 | classifiers = 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Framework :: Django :: 3.0 15 | Framework :: Django :: 3.1 16 | Framework :: Django :: 3.2 17 | Framework :: Django :: 4.0 18 | Intended Audience :: Developers 19 | License :: OSI Approved :: BSD License 20 | Operating System :: OS Independent 21 | Programming Language :: Python 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Topic :: Internet :: WWW/HTTP 27 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 28 | 29 | [options] 30 | include_package_data = true 31 | packages = find: 32 | python_requires = >=3.8 33 | install_requires = 34 | Django >= 3.0 35 | -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | Once the package is installed, this will by default enforce that a user will only have *one* logged in session. This will *not* proactively logout existing sessions: only if the user 6 | logs in with another browser or device, the old session(s) will be closed. 7 | 8 | You can disable the single session behavior by specifying the `SINGLE_USER_SESSION` setting in `settings.py` and thus setting this value to `False` (or any other value with truthiness `False`). 9 | 10 | You can customise this behaviour by making the `SINGLE_USER_SESSION` setting be a string representing the name of a function which takes a user 11 | object as an argument. If this function returns `True` then the user will be logged out. If it returns `False` then the user will not be logged out. 12 | 13 | The tool will also clean up *all* sessions of a user in case that user logs out. This thus means that if a user logs out on one browser/device, they will log out on all other browsers/devices as well. This functionality is still enabled if `SINGLE_USER_SESSION` is set to `False`. You can disable this by setting the `LOGOUT_ALL_SESSIONS` setting in `settings.py` to `False` (or any other value with truthiness `False`). -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | The package can be fetched as `django-single-session`, so for example with `pip` with: 6 | 7 | .. code-block:: console 8 | 9 | pip3 install django-single-session 10 | 11 | 12 | One can install the app by adding the `single_session` app to the `INSTALLED_APPS` setting: 13 | 14 | .. code-block:: py3 15 | 16 | # settings.py 17 | 18 | # ... 19 | 20 | INSTALLED_APPS = [ 21 | # ..., 22 | 'django.contrib.sessions', 23 | # ..., 24 | 'single_session' 25 | # ... 26 | ] 27 | 28 | MIDDLEWARE = [ 29 | # ..., 30 | 'django.contrib.sessions.middleware.SessionMiddleware', 31 | # ..., 32 | 'django.contrib.auth.middleware.AuthenticationMiddlware', 33 | # ... 34 | ] 35 | 36 | 37 | In order to work properly, the `SessionMiddleware` and `AuthenticationMiddleware` is be necessary, or another 38 | middleware class that will add a `.session` and `.user` attribute on the request object and 39 | will trigger the `user_logged_in` and `user_logged_out` signals with the proper session and user. 40 | 41 | You also need to be using the database session backend 42 | 43 | and running `migrate` to migrate the database properly: 44 | 45 | .. code-block:: console 46 | 47 | python3 manage.py migrate single_session 48 | 49 | -------------------------------------------------------------------------------- /single_session/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | 5 | class SingleSessionConfig(AppConfig): 6 | """ 7 | The app config for the single_session app. 8 | """ 9 | 10 | name = "single_session" 11 | verbose_name = "Single session" 12 | default_auto_field = "django.db.models.BigAutoField" 13 | 14 | def ready(self): 15 | """ 16 | This ready() method will load the signals that will be triggered 17 | if a user has logged in or logged out, and will populate the `ModelAdmin` 18 | for the user model, given there is such ModelAdmin. 19 | """ 20 | from single_session import signals # noqa 21 | from django.contrib.auth import get_user_model 22 | from django.contrib.sessions.models import Session 23 | 24 | if "django.contrib.admin" not in settings.INSTALLED_APPS: 25 | return 26 | from django.contrib import admin 27 | from single_session.admin import __actions__, __permissions__ 28 | 29 | User = get_user_model() 30 | UserAdmin = admin.site._registry.get(User) 31 | if UserAdmin is not None: 32 | for perm in __permissions__: 33 | setattr(UserAdmin, perm.__name__, perm) 34 | user_actions = UserAdmin.actions = list(UserAdmin.actions) 35 | user_actions += __actions__ 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Willem Van Onsem (c) 2022 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Willem Van Onsem nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /single_session/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.sessions.models import Session 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | @admin.action( 7 | description=_("Log out the user on all sessions"), 8 | permissions=["single_session_logout"], 9 | ) 10 | def logout_user_on_all_sessions(modeladmin, request, queryset): 11 | """ 12 | An action to log out all the users that are not admin users. 13 | This requires a single_session.logout permission. 14 | """ 15 | Session.objects.filter( 16 | usersession__user__in=queryset, usersession__user__is_superuser=False 17 | ).delete() 18 | 19 | 20 | @admin.action( 21 | description=_("Log out the (admin) user on all sessions"), 22 | permissions=["single_session_logout_all"], 23 | ) 24 | def logout_all_users_on_all_sessions(modeladmin, request, queryset): 25 | """ 26 | An action to log out all the users including admin users. 27 | This requires a single_session.logout_all permission. 28 | """ 29 | Session.objects.filter(usersession__user__in=queryset).delete() 30 | 31 | 32 | def has_single_session_logout_permission(request, instance=None): 33 | """ 34 | Checks if the user has a single_session.logout permission. 35 | """ 36 | return request.user.has_perm("single_session.logout") 37 | 38 | 39 | def has_single_session_logout_all_permission(request, instance=None): 40 | """ 41 | Checks if the user has a single_session.logout_all permission. 42 | """ 43 | return request.user.has_perm("single_session.logout_all") 44 | 45 | 46 | __permissions__ = [ 47 | has_single_session_logout_permission, 48 | has_single_session_logout_all_permission, 49 | ] 50 | 51 | __actions__ = [logout_user_on_all_sessions, logout_all_users_on_all_sessions] 52 | -------------------------------------------------------------------------------- /single_session/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.models import Session 3 | from django.db import models 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | if settings.SESSION_ENGINE != "django.contrib.sessions.backends.db": 9 | raise ImproperlyConfigured( 10 | _( 11 | "The django-single-session package can only work with the 'django.contrib.sessions.backends.db' as SESSION_ENGINE." 12 | ) 13 | ) 14 | 15 | if "django.contrib.sessions" not in settings.INSTALLED_APPS: 16 | raise ImproperlyConfigured( 17 | _( 18 | "The django-single-session package can only work if the 'django.contrib.sessions' app is installed in INSTALLED_APPS." 19 | ) 20 | ) 21 | 22 | 23 | class UserSession(models.Model): 24 | """ 25 | A model used to store the relation between the session ids and the user model. 26 | This is used to determine efficiently what session(s) belong to what user. 27 | 28 | The model also defines two extra permissions that can be used to log out all users, 29 | and all users except the admin users. 30 | """ 31 | 32 | session = models.OneToOneField( 33 | Session, 34 | on_delete=models.CASCADE, 35 | primary_key=True, 36 | related_name="usersessions", 37 | related_query_name="usersession", 38 | ) 39 | user = models.ForeignKey( 40 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+" 41 | ) 42 | 43 | def __str__(self): 44 | return f"{self.user.username} - {self.session.session_key}" 45 | 46 | class Meta: 47 | permissions = [ 48 | ("logout", _("Logout user sessions in bulk for a given user.")), 49 | ("logout_all", _("Logout user sessions in bulk for a given (admin) user.")), 50 | ] 51 | -------------------------------------------------------------------------------- /single_session/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # django-single-session, Dutch translation. 2 | # Copyright (C) 2022 Willem Van Onsem 3 | # This file is distributed under the same license as the django-single-session package. 4 | # Willem Van Onsem , 2022. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 0.2\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-08-01 23:13+0000\n" 12 | "PO-Revision-Date: 2022-07-28 07:00+0000\n" 13 | "Last-Translator: Willem Van Onsem \n" 14 | "Language-Team: Dutch \n" 15 | "Language: Dutch\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: single_session/admin.py:7 22 | msgid "Log out the user on all sessions" 23 | msgstr "Log de gebruiker uit voor alle sessies" 24 | 25 | #: single_session/admin.py:21 26 | msgid "Log out the (admin) user on all sessions" 27 | msgstr "Log de (administrator) gebruiker uit voor alle sessies" 28 | 29 | #: single_session/models.py:11 30 | msgid "" 31 | "The django-single-session package can only work with the 'django.contrib." 32 | "sessions.backends.db' as SESSION_ENGINE." 33 | msgstr "" 34 | "Het django-single-session pakket kan enkel werken met'django.contrib." 35 | "sessions.backends.db' als SESSION_ENGINE." 36 | 37 | #: single_session/models.py:18 38 | msgid "" 39 | "The django-single-session package can only work if the 'django.contrib." 40 | "sessions' app is installed in INSTALLED_APPS." 41 | msgstr "" 42 | "Het django-single-session pakket kan enkel werken als de'django.contrib." 43 | "sessions' app is geïnstalleerd in INSTALLED_APPS." 44 | 45 | #: single_session/models.py:48 46 | msgid "Logout user sessions in bulk for a given user." 47 | msgstr "Log de gebruikersessies uit voor de gegeven gebruiker(s)." 48 | 49 | #: single_session/models.py:49 50 | msgid "Logout user sessions in bulk for a given (admin) user." 51 | msgstr "" 52 | "Log de gebruikersessies uit voor de gegeven (administrator) gebruiker(s)." 53 | 54 | #: single_session/signals.py:20 55 | #, python-format 56 | msgid "" 57 | "The django-single-session package setting SINGLE_USER_SESSION must be a " 58 | "boolean or a string to a callable that takes a User object. Cannot import " 59 | "%(setting)r" 60 | msgstr "" 61 | "De instelling SINGLE_USER_SESSION voor het django-single-session pakket " 62 | "moeteen boolean of strings zijn die naar een callable gaat die een User-" 63 | "object gebruikt. Kan de instelling %(setting)r niet importeren" 64 | 65 | -------------------------------------------------------------------------------- /single_session/signals.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import user_logged_in, user_logged_out 3 | from django.contrib.sessions.models import Session 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.dispatch.dispatcher import receiver 6 | from django.test.signals import setting_changed 7 | from django.utils.module_loading import import_string 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from single_session.models import UserSession 11 | 12 | 13 | single_user_session_setting = getattr(settings, "SINGLE_USER_SESSION", None) 14 | if isinstance(single_user_session_setting, str): 15 | try: 16 | import_string(single_user_session_setting) 17 | except ImportError: 18 | raise ImproperlyConfigured( 19 | _( 20 | "The django-single-session package setting SINGLE_USER_SESSION must be a boolean or a string to a callable that takes a User object. Cannot import %(setting)r" 21 | ) 22 | % {"setting": single_user_session_setting} 23 | ) 24 | 25 | 26 | def remove_other_sessions(sender, user, request, **kwargs): 27 | """ 28 | A signal handler attached to the user_logged_in signal that will 29 | create a UserSession that associates the session with the 30 | user that has logged in. If the SINGLE_USER_SESSION setting 31 | is enabled (by default), it will remove all the old sessions 32 | associated to that user. 33 | """ 34 | # remove other sessions 35 | session_validate_function = None 36 | session_setting = getattr(settings, "SINGLE_USER_SESSION", True) 37 | if isinstance(session_setting, str): 38 | session_validate_function = import_string( 39 | getattr(settings, "SINGLE_USER_SESSION") 40 | ) 41 | if ( 42 | session_validate_function and session_validate_function(user) 43 | ) or session_setting is True: 44 | remove_all_sessions(sender, user, request, **kwargs) 45 | 46 | # save current session 47 | request.session.save() 48 | 49 | # create a link from the user to the current session (for later removal) 50 | UserSession.objects.get_or_create( 51 | session_id=request.session.session_key, defaults={"user": user} 52 | ) 53 | 54 | 55 | def remove_all_sessions(sender, user, request, **kwargs): 56 | """ 57 | A signal handler attached to the user_logged_out signal that will 58 | remove all sessions associated to that user. This will run if the 59 | LOGOUT_ALL_SESSIONS setting is enabled. 60 | """ 61 | # remove other sessions 62 | if user is not None: 63 | Session.objects.filter(usersession__user=user).delete() 64 | 65 | 66 | @receiver(setting_changed) 67 | def change_settings(sender, setting, value, enter, **kwargs): 68 | """ 69 | A signal handler that is attached to the setting_changed handler 70 | that will subscribe and unsubscribe the signal handlers to the 71 | proper signals. 72 | """ 73 | if setting == "LOGOUT_ALL_SESSIONS": 74 | if value or not enter: # teardown: value is None 75 | user_logged_out.connect(remove_all_sessions) 76 | else: 77 | user_logged_out.disconnect(remove_all_sessions) 78 | 79 | 80 | user_logged_in.connect(remove_other_sessions) 81 | if getattr(settings, "LOGOUT_ALL_SESSIONS", True): 82 | user_logged_out.connect(remove_all_sessions) 83 | -------------------------------------------------------------------------------- /single_session/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.sessions.models import Session 3 | from django.test import TestCase 4 | from django.test.client import Client 5 | from django.test.utils import override_settings 6 | from single_session.models import UserSession 7 | 8 | 9 | def validate_user(user): 10 | """Log out the user foo, but not the user bar""" 11 | if user.username == "foo": 12 | return True 13 | return False 14 | 15 | 16 | class SingleSessionTest(TestCase): 17 | def setUp(self): 18 | User = get_user_model() 19 | self.user1 = User.objects.create_superuser(username="foo", password="foo") 20 | self.user2 = User.objects.create_superuser(username="bar", password="bar") 21 | 22 | def _pre_setup(self): 23 | super()._pre_setup() 24 | self.client2 = Client() 25 | self.client3 = Client() 26 | 27 | def login_foo(self, client, items=1): 28 | self.assertTrue(client.login(username="foo", password="foo")) 29 | self.validate_session_number(items=items) 30 | 31 | def login_bar(self, client, items=1): 32 | self.assertTrue(client.login(username="bar", password="bar")) 33 | self.validate_session_number(items=items) 34 | 35 | def logout(self, client, items=0): 36 | client.logout() 37 | self.validate_session_number(items=items) 38 | 39 | def validate_session_number(self, items=1): 40 | self.assertEqual(items, UserSession.objects.count(), UserSession.objects.all()) 41 | self.assertEqual(items, Session.objects.count(), Session.objects.all()) 42 | 43 | def test_login_logout_scenario(self): 44 | self.validate_session_number(0) 45 | self.login_foo(self.client) 46 | self.login_foo(self.client2) # login in new browser, logs out the old one 47 | self.login_bar(self.client3, 2) 48 | self.login_foo(self.client, 2) 49 | self.logout(self.client, 1) 50 | self.logout(self.client3) 51 | 52 | @override_settings(SINGLE_USER_SESSION="single_session.tests.validate_user") 53 | def test_login_logout_scenario_with_per_user_configuration(self): 54 | self.validate_session_number(0) 55 | self.login_foo(self.client) 56 | self.login_foo(self.client2) # second session for foo, logs out 57 | self.login_bar(self.client, 2) 58 | self.login_bar(self.client3, 3) # second session for bar, stays logged in 59 | self.logout(self.client, 1) # logs out both sessions 60 | self.logout(self.client2) 61 | self.logout(self.client3) 62 | 63 | @override_settings(SINGLE_USER_SESSION=False) 64 | def test_login_logout_scenario_without_single_session(self): 65 | self.validate_session_number(0) 66 | self.login_foo(self.client) 67 | self.login_foo(self.client2, 2) # second session for foo 68 | self.login_bar(self.client3, 3) 69 | self.login_foo(self.client, 3) # another login on client1 70 | self.logout(self.client, 1) # logs out both sessions 71 | self.logout(self.client3) 72 | 73 | @override_settings(SINGLE_USER_SESSION=False, LOGOUT_ALL_SESSIONS=False) 74 | def test_login_logout_scenario_without_logout_all(self): 75 | self.validate_session_number(0) 76 | self.login_foo(self.client) 77 | self.login_foo(self.client2, 2) 78 | self.login_bar(self.client3, 3) 79 | self.login_foo(self.client, 3) 80 | self.logout(self.client, 2) # logs out only one session 81 | self.logout(self.client3, 1) 82 | -------------------------------------------------------------------------------- /docs/source/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_package project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "secret-key" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "single_session", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "testproject.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "testproject.wsgi.application" 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": BASE_DIR / "db.sqlite3", 80 | } 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 90 | }, 91 | { 92 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 93 | }, 94 | { 95 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 96 | }, 97 | { 98 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 99 | }, 100 | ] 101 | 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 105 | 106 | LANGUAGE_CODE = "en-us" 107 | 108 | TIME_ZONE = "UTC" 109 | 110 | USE_I18N = True 111 | 112 | USE_L10N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 119 | 120 | STATIC_ROOT = "/var" 121 | STATIC_URL = "/static/" 122 | 123 | # Default primary key field type 124 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 125 | 126 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django-single-session 2 | 3 | [![PyPi version](https://badgen.net/pypi/v/django-single-session/)](https://pypi.python.org/pypi/django-single-session/) 4 | [![Documentation Status](https://readthedocs.org/projects/django-single-session/badge/?version=latest)](http://django-single-session.readthedocs.io/?badge=latest) 5 | [![PyPi license](https://badgen.net/pypi/license/django-single-session/)](https://pypi.python.org/pypi/django-single-session/) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | 8 | A Django app that enforces that a user has only one active session: if the user logs in on another browser/device, then the previous sessions will log out. 9 | 10 | The app will also add an extra action to the `ModelAdmin` of the user model (if there is such `ModelAdmin`), that will alow to log out all sessions of a given (set of) user(s). 11 | 12 | ## Installation 13 | 14 | The package can be fetched as `django-single-session`, so for example with `pip` with: 15 | 16 | ```shell 17 | pip3 install django-single-session 18 | ``` 19 | 20 | One can install the app by adding the `single_session` app to the `INSTALLED_APPS` setting: 21 | 22 | ```python3 23 | # settings.py 24 | 25 | # ... 26 | 27 | INSTALLED_APPS = [ 28 | # ..., 29 | 'django.contrib.sessions', 30 | # ..., 31 | 'single_session' 32 | # ... 33 | ] 34 | 35 | MIDDLEWARE = [ 36 | # ..., 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | # ..., 39 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 40 | # ... 41 | ] 42 | 43 | SESSION_ENGINE = 'django.contrib.sessions.backends.db' 44 | ``` 45 | 46 | For the `SESSION_ENGINE` setting, the database backend, `django.contrib.sessions.backends.db` should be used, since that is the one where the item is linking to. 47 | 48 | In order to work properly, the `SessionMiddleware` and `AuthenticationMiddleware` will be necessary, or another middleware class that will add a `.session` and `.user` attribute on the 49 | request object and will trigger the `user_logged_in` and `user_logged_out` signals with the proper session and user. 50 | 51 | and running `migrate` to migrate the database properly: 52 | 53 | ```shell 54 | python3 manage.py migrate single_session 55 | ``` 56 | 57 | This will by default enforce that a user will only have *one* logged in session. This will *not* proactively logout existing sessions: only if the user logs in with another browser or device, 58 | the old session(s) will be closed. 59 | 60 | ## Configuration 61 | 62 | One can disable the single session behavior by setting `SINGLE_USER_SESSION` in `settings.py` to `False` (or any other value with truthiness `False`). 63 | 64 | You can customise this behaviour by making the `SINGLE_USER_SESSION` setting be a string representing the name of a function which takes a user object as an argument. If this function returns `True` then the user will be logged out. If it returns `False` then the user will not be logged out. 65 | 66 | The tool will also clean up *all* sessions of a user in case that user logs out. This thus means that if a user logs out on one browser/device, they will log out on all other browsers/devices as well. This functionality is still enabled if `SINGLE_USER_SESSION` is set to `False`. You can disable this by setting the `LOGOUT_ALL_SESSIONS` setting in `settings.py` to `False` (or any other value with truthiness `False`). 67 | 68 | ## Logging out (other) users 69 | 70 | If there is a `ModelAdmin` for the user model (if you use the default user model, then there is such `ModelAdmin`), and the `django.contrib.admin` package is installed, 71 | then that `ModelAdmin` will have extra actions to log out normal users and admin users. 72 | 73 | You can thus select users, and log these out with the "*Log out the user on all sessions*" action. This will invalidate all the sessions for (all) the selected user(s). In order to do this, 74 | the `single_session.logout` permission is required, so only admin users and users with such permission can log out other users. Users with such permission can log out users, but 75 | *not* admin users. 76 | 77 | There is an extra permission named `single_session.logout_all` to log out all users, including *admin* users. Users with such permission can thus also log out admin users, so it 78 | might be better not to give such permission to all (staff) users. 79 | 80 | # Contributors 81 | 82 | - [@alastair](https://github.com/alastair) implemented a system such that one can add a string that points to a callback for the `SINGLE_USER_SESSION` setting, to make it possible to determine what users should be logged out. 83 | 84 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-shop-discounts.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-shop-discounts.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /.github/workflows/django-single-session-ci.yml: -------------------------------------------------------------------------------- 1 | name: django-single-session CI 2 | on: push 3 | jobs: 4 | black: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: psf/black@stable 9 | with: 10 | options: "--check" 11 | 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.7', '3.8', '3.9', '3.10'] 17 | steps: 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - run: sudo apt install python3-django 23 | - run: pip install Django 24 | - run: django-admin startproject testproject 25 | - name: checkout code 26 | uses: actions/checkout@v2.3.1 27 | with: 28 | path: 'testproject_temp' 29 | - run: "mv testproject_temp/* testproject/" 30 | - run: pip install -r requirements.txt 31 | working-directory: 'testproject' 32 | - run: python manage.py test --settings=docs.source.settings 33 | working-directory: 'testproject' 34 | 35 | no-makemigrations: 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | python-version: ['3.7', '3.8', '3.9', '3.10'] 40 | steps: 41 | - name: Set up Python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - run: sudo apt install python3-django 46 | - run: pip install Django 47 | - run: django-admin startproject testproject 48 | - name: checkout code 49 | uses: actions/checkout@v2.3.1 50 | with: 51 | path: 'testproject_temp' 52 | - run: "mv testproject_temp/* testproject/" 53 | - run: pip install -r requirements.txt 54 | working-directory: 'testproject' 55 | - run: python manage.py makemigrations --dry-run --settings=docs.source.settings 56 | working-directory: 'testproject' 57 | shell: bash 58 | 59 | no-makemessages: 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python-version: ['3.7', '3.8', '3.9', '3.10'] 64 | locale: [nl] 65 | steps: 66 | - name: Set up Python 67 | uses: actions/setup-python@v4 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | - run: sudo apt install gettext python3-django 71 | - run: pip install Django 72 | - run: django-admin startproject testproject 73 | - name: checkout code 74 | uses: actions/checkout@v2.3.1 75 | with: 76 | path: 'testproject_temp' 77 | - run: | 78 | shopt -s dotglob 79 | mv testproject_temp/* testproject/ 80 | - run: pip install -r requirements.txt 81 | working-directory: 'testproject' 82 | - run: python manage.py makemessages --locale=${{ matrix.locale }} --settings=docs.source.settings 83 | working-directory: 'testproject' 84 | - run: git diff --ignore-matching-lines='^"POT-Creation-Date:' --ignore-blank-lines --exit-code 85 | working-directory: 'testproject' 86 | 87 | no-compilemessages: 88 | runs-on: ubuntu-latest 89 | strategy: 90 | matrix: 91 | python-version: ['3.7', '3.8', '3.9', '3.10'] 92 | steps: 93 | - name: Set up Python 94 | uses: actions/setup-python@v4 95 | with: 96 | python-version: ${{ matrix.python-version }} 97 | - run: sudo apt install gettext python3-django 98 | - run: pip install Django 99 | - run: django-admin startproject testproject 100 | - name: checkout code 101 | uses: actions/checkout@v2.3.1 102 | with: 103 | path: 'testproject_temp' 104 | - run: | 105 | shopt -s dotglob 106 | mv testproject_temp/* testproject/ 107 | - run: pip install -r requirements.txt 108 | working-directory: 'testproject' 109 | - run: python manage.py compilemessages --settings=docs.source.settings 110 | working-directory: 'testproject' 111 | - run: git diff --exit-code 112 | working-directory: 'testproject' 113 | 114 | build: 115 | runs-on: ubuntu-latest 116 | strategy: 117 | matrix: 118 | python-version: ['3.7', '3.8', '3.9', '3.10'] 119 | steps: 120 | - name: checkout code 121 | uses: actions/checkout@v2.3.1 122 | - name: Set up Python 123 | uses: actions/setup-python@v4 124 | with: 125 | python-version: ${{ matrix.python-version }} 126 | - run: | 127 | pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 128 | python -m setuptools_scm 129 | python setup.py sdist bdist_wheel 130 | 131 | test-publish: 132 | runs-on: ubuntu-latest 133 | if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') 134 | needs: [black, build, test, no-makemigrations, no-makemessages, no-compilemessages] 135 | steps: 136 | - name: checkout code 137 | uses: actions/checkout@v2.3.1 138 | - name: Set up Python 139 | uses: actions/setup-python@v4 140 | - run: | 141 | pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 142 | python -m setuptools_scm 143 | python setup.py sdist bdist_wheel 144 | - name: Publish package 145 | uses: pypa/gh-action-pypi-publish@release/v1 146 | with: 147 | password: ${{ secrets.TEST_PYPI_TOKEN }} 148 | repository_url: https://test.pypi.org/legacy/ 149 | 150 | publish: 151 | runs-on: ubuntu-latest 152 | needs: [test-publish] 153 | if: startsWith(github.ref, 'refs/tags/') 154 | steps: 155 | - name: checkout code 156 | uses: actions/checkout@v2.3.1 157 | - name: Set up Python 158 | uses: actions/setup-python@v4 159 | - run: | 160 | pip install setuptools>=38.6.0 twine>=1.11.0 wheel>=0.31.0 setuptools_scm>=6.2 161 | python -m setuptools_scm 162 | python setup.py sdist bdist_wheel 163 | - name: Publish package 164 | uses: pypa/gh-action-pypi-publish@release/v1 165 | with: 166 | password: ${{ secrets.PYPI_TOKEN }} 167 | --------------------------------------------------------------------------------