├── tests ├── __init__.py ├── models.py ├── Dockerfile ├── urls.py ├── test_models.py ├── test_commands.py ├── test_client.py ├── test_middleware.py ├── settings.py ├── test_views.py ├── test_admin.py ├── utils.py ├── generate_mmdb.pl ├── test_sessionstore.py └── test_template_filters.py ├── example ├── __init__.py ├── manage.py ├── wsgi.py ├── urls.py ├── templates │ └── _base.html ├── middleware.py └── settings.py ├── user_sessions ├── utils │ └── __init__.py ├── backends │ ├── __init__.py │ └── db.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clearsessions.py │ │ └── migratesessions.py ├── migrations │ ├── __init__.py │ ├── 0004_alter_session_expire_date.py │ ├── 0003_auto_20161205_1516.py │ ├── 0002_auto_20151208_1536.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── user_sessions.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── he │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl_PL │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_CN │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── apps.py ├── __init__.py ├── urls.py ├── middleware.py ├── templates │ └── user_sessions │ │ ├── _base.html │ │ └── session_list.html ├── models.py ├── admin.py └── views.py ├── docs ├── _static │ ├── admin-view.png │ └── custom-view.png ├── reference.rst ├── index.rst ├── usage.rst ├── installation.rst ├── release-notes.rst ├── Makefile └── conf.py ├── .bumpversion.cfg ├── MANIFEST.in ├── .tx └── config ├── .gitignore ├── CONTRIBUTING.rst ├── .coveragerc ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── Makefile ├── .github ├── workflows │ ├── release.yml │ └── test.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_sessions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/admin-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/docs/_static/admin-view.png -------------------------------------------------------------------------------- /docs/_static/custom-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/docs/_static/custom-view.png -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.7.1 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | -------------------------------------------------------------------------------- /user_sessions/management/commands/clearsessions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.management.commands.clearsessions import Command # noqa isort:skip 2 | -------------------------------------------------------------------------------- /user_sessions/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/he/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/he/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | prune user_sessions/locale 3 | recursive-include user_sessions/locale * 4 | recursive-include user_sessions/templates * 5 | -------------------------------------------------------------------------------- /user_sessions/locale/pl_PL/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/pl_PL/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /user_sessions/locale/zh_CN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-user-sessions/master/user_sessions/locale/zh_CN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM perl:latest 2 | 3 | RUN cpanm MaxMind::DB::Writer 4 | 5 | COPY generate_mmdb.pl / 6 | 7 | VOLUME ["/data"] 8 | 9 | CMD ["perl", "/generate_mmdb.pl"] 10 | -------------------------------------------------------------------------------- /user_sessions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserSessionsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'user_sessions' 7 | verbose_name = 'User Sessions' 8 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | type = PO 4 | 5 | [django-user-sessions.user_sessions] 6 | file_filter = user_sessions/locale//LC_MESSAGES/django.po 7 | source_file = user_sessions/locale/en/LC_MESSAGES/django.po 8 | source_lang = en 9 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /example/database.sqlite3 2 | /example/GeoLiteCity.dat 3 | /django_user_sessions.egg-info/ 4 | /tests/test_city.mmdb 5 | /tests/test_country.mmdb 6 | 7 | /htmlcov/ 8 | 9 | /.coverage 10 | coverage.xml 11 | /.tox/ 12 | 13 | /docs/_build/ 14 | /GeoLite2-City.mmdb 15 | 16 | __pycache__ 17 | /venv/ 18 | /.eggs/ 19 | /build/ 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 6 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | tests 5 | user_sessions 6 | 7 | [report] 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain about missing debug-only code: 13 | def __repr__ 14 | 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise NotImplementedError 18 | -------------------------------------------------------------------------------- /user_sessions/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib.metadata import version 3 | except ImportError: 4 | from pkg_resources import DistributionNotFound, get_distribution 5 | 6 | try: 7 | __version__ = get_distribution("django-user-sessions").version 8 | except DistributionNotFound: 9 | # package is not installed 10 | __version__ = None 11 | else: 12 | __version__ = version("django-user-sessions") 13 | -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /user_sessions/migrations/0004_alter_session_expire_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('user_sessions', '0003_auto_20161205_1516'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='session', 15 | name='expire_date', 16 | field=models.DateTimeField(db_index=True, verbose_name='expire date'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpResponse 3 | from django.urls import include, path 4 | 5 | admin.autodiscover() 6 | 7 | 8 | def empty(request): 9 | return HttpResponse('') 10 | 11 | 12 | def modify_session(request): 13 | request.session['FOO'] = 'BAR' 14 | return HttpResponse('') 15 | 16 | 17 | urlpatterns = [ 18 | path('', empty), 19 | path('modify_session/', modify_session), 20 | path('admin/', admin.site.urls), 21 | path('', include('user_sessions.urls', namespace='user_sessions')), 22 | ] 23 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | Middleware 5 | ---------- 6 | .. autoclass:: user_sessions.middleware.SessionMiddleware 7 | 8 | Models 9 | ------ 10 | .. autoclass:: user_sessions.models.Session 11 | 12 | Session Backends 13 | ---------------- 14 | .. autoclass:: user_sessions.backends.db.SessionStore 15 | 16 | Template Tags 17 | ------------- 18 | .. automodule:: user_sessions.templatetags.user_sessions 19 | :members: 20 | 21 | Views 22 | ----- 23 | .. autoclass:: user_sessions.views.SessionListView 24 | .. autoclass:: user_sessions.views.SessionDeleteView 25 | -------------------------------------------------------------------------------- /user_sessions/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import SessionDeleteOtherView, SessionDeleteView, SessionListView 4 | 5 | app_name = 'user_sessions' 6 | urlpatterns = [ 7 | path( 8 | 'account/sessions/', 9 | view=SessionListView.as_view(), 10 | name='session_list', 11 | ), 12 | path( 13 | 'account/sessions/other/delete/', 14 | view=SessionDeleteOtherView.as_view(), 15 | name='session_delete_other', 16 | ), 17 | path( 18 | 'account/sessions//delete/', 19 | view=SessionDeleteView.as_view(), 20 | name='session_delete', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django User Sessions's Documentation 2 | ==================================== 3 | 4 | Django includes excellent built-in sessions, however all the data is hidden 5 | away into base64 encoded data. This makes it very difficult to run a query on 6 | all active sessions for a particular user. `django-user-sessions` fixes this 7 | and makes session objects a first class citizen like other ORM objects. 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | installation 15 | usage 16 | reference 17 | release-notes 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /user_sessions/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.middleware import ( 3 | SessionMiddleware as DjangoSessionMiddleware, 4 | ) 5 | 6 | 7 | class SessionMiddleware(DjangoSessionMiddleware): 8 | """ 9 | Middleware that provides ip and user_agent to the session store. 10 | """ 11 | def process_request(self, request): 12 | session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None) 13 | request.session = self.SessionStore( 14 | ip=request.META.get('REMOTE_ADDR', ''), 15 | user_agent=request.META.get('HTTP_USER_AGENT', ''), 16 | session_key=session_key 17 | ) 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | # vim: set nospell: 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.5.0 7 | hooks: 8 | - id: trailing-whitespace 9 | args: [--markdown-linebreak-ext=md] 10 | - id: end-of-file-fixer 11 | - id: check-toml 12 | - id: check-added-large-files 13 | - id: debug-statements 14 | - id: trailing-whitespace 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | # Ruff version. 17 | rev: v0.3.2 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | -------------------------------------------------------------------------------- /user_sessions/migrations/0003_auto_20161205_1516.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.4 on 2016-12-05 15:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('user_sessions', '0002_auto_20151208_1536'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='session', 15 | name='ip', 16 | field=models.GenericIPAddressField(blank=True, null=True, verbose_name='IP'), 17 | ), 18 | migrations.AlterField( 19 | model_name='session', 20 | name='user_agent', 21 | field=models.CharField(blank=True, max_length=200, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /user_sessions/migrations/0002_auto_20151208_1536.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9 on 2015-12-08 15:36 2 | 3 | from django.db import migrations, models 4 | 5 | import user_sessions.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('user_sessions', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelManagers( 16 | name='session', 17 | managers=[ 18 | ('objects', user_sessions.models.SessionManager()), 19 | ], 20 | ), 21 | migrations.AlterField( 22 | model_name='session', 23 | name='ip', 24 | field=models.GenericIPAddressField(verbose_name='IP'), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, re_path, reverse_lazy 4 | from django.views.generic.base import RedirectView 5 | 6 | admin.autodiscover() 7 | 8 | urlpatterns = [ 9 | re_path( 10 | '^$', 11 | RedirectView.as_view( 12 | url=reverse_lazy('user_sessions:session_list'), 13 | permanent=True, 14 | ), 15 | name='home', 16 | ), 17 | re_path(r'', include('user_sessions.urls', namespace='user_sessions')), 18 | re_path(r'^admin/', admin.site.urls), 19 | ] 20 | 21 | if settings.DEBUG: 22 | import debug_toolbar 23 | urlpatterns = [ 24 | re_path(r'^__debug__/', include(debug_toolbar.urls)), 25 | ] + urlpatterns 26 | -------------------------------------------------------------------------------- /example/templates/_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 8 | {% block content_wrapper %} 9 | 18 | 19 |
20 | {% block content %}{% endblock %} 21 |
22 | {% endblock %} 23 | 24 | 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | ; Minimum version of Tox 3 | minversion = 1.8 4 | envlist = 5 | ; https://docs.djangoproject.com/en/4.2/faq/install/#what-python-version-can-i-use-with-django 6 | py{37}-dj32 7 | py{38,39,310}-dj32 8 | py{311,312}-dj{42,main} 9 | 10 | [gh-actions] 11 | python = 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 3.12: py312 17 | 18 | [gh-actions:env] 19 | DJANGO = 20 | 3.2: dj32 21 | 4.2: dj42 22 | main: djmain 23 | 24 | [testenv] 25 | commands = 26 | make generate-mmdb-fixtures 27 | coverage run {envbindir}/django-admin test -v 2 --pythonpath=./ --settings=tests.settings 28 | coverage report 29 | coverage xml 30 | deps = 31 | coverage 32 | dj32: Django>=3.2,<4.0 33 | dj42: Django>=4.2,<4.3 34 | djmain: https://github.com/django/django/archive/main.tar.gz 35 | geoip2 36 | ignore_outcome = 37 | djmain: True 38 | allowlist_externals = make 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Bouke Haarsma 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /user_sessions/templates/user_sessions/_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 11 | 12 | 13 |

Provide a template named 14 | user_sessions/_base.html to style this page and remove this message.

15 | 16 | {% block content_wrapper %} 17 |
18 | {% block content %}{% endblock %} 19 |
20 | {% endblock %} 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TARGET?=tests 2 | 3 | .PHONY: ruff example test coverage 4 | 5 | ruff: 6 | ruff user_sessions example tests 7 | 8 | example: 9 | DJANGO_SETTINGS_MODULE=example.settings PYTHONPATH=. \ 10 | django-admin.py runserver 11 | 12 | check: 13 | DJANGO_SETTINGS_MODULE=example.settings PYTHONPATH=. \ 14 | python -Wd example/manage.py check 15 | 16 | generate-mmdb-fixtures: 17 | docker --context=default buildx build -f tests/Dockerfile --tag test-mmdb-maker tests 18 | docker run --rm --volume $$(pwd)/tests:/data test-mmdb-maker 19 | 20 | test: generate-mmdb-fixtures 21 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 22 | django-admin.py test ${TARGET} 23 | 24 | migrations: 25 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 26 | django-admin.py makemigrations user_sessions 27 | 28 | coverage: 29 | coverage erase 30 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 31 | coverage run example/manage.py test ${TARGET} 32 | coverage html 33 | coverage report 34 | 35 | tx-pull: 36 | tx pull -a 37 | cd user_sessions; django-admin.py compilemessages 38 | 39 | tx-push: 40 | cd user_sessions; django-admin.py makemessages -l en 41 | tx push -s 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-user-sessions' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.x 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-user-sessions/upload 41 | -------------------------------------------------------------------------------- /example/middleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | 3 | 4 | class SetRemoteAddrFromForwardedFor(MiddlewareMixin): 5 | """ 6 | Middleware that sets REMOTE_ADDR based on HTTP_X_FORWARDED_FOR, if the 7 | latter is set. This is useful if you're sitting behind a reverse proxy that 8 | causes each request's REMOTE_ADDR to be set to 127.0.0.1. 9 | 10 | Note that this does NOT validate HTTP_X_FORWARDED_FOR. If you're not behind 11 | a reverse proxy that sets HTTP_X_FORWARDED_FOR automatically, do not use 12 | this middleware. Anybody can spoof the value of HTTP_X_FORWARDED_FOR, and 13 | because this sets REMOTE_ADDR based on HTTP_X_FORWARDED_FOR, that means 14 | anybody can "fake" their IP address. Only use this when you can absolutely 15 | trust the value of HTTP_X_FORWARDED_FOR. 16 | """ 17 | def process_request(self, request): 18 | try: 19 | real_ip = request.META['HTTP_X_REAL_IP'] 20 | except KeyError: 21 | return None 22 | else: 23 | # HTTP_X_FORWARDED_FOR can be a comma-separated list of IPs. The 24 | # client's IP will be the first one. 25 | real_ip = real_ip.split(",")[0].strip() 26 | request.META['REMOTE_ADDR'] = real_ip 27 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib import auth 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | 5 | from user_sessions.backends.db import SessionStore 6 | from user_sessions.models import Session 7 | 8 | 9 | class ModelTest(TestCase): 10 | def test_get_decoded(self): 11 | User.objects.create_user('bouke', '', 'secret', id=1) 12 | store = SessionStore(user_agent='Python/2.7', ip='127.0.0.1') 13 | store[auth.SESSION_KEY] = 1 14 | store['foo'] = 'bar' 15 | store.save() 16 | 17 | session = Session.objects.get(pk=store.session_key) 18 | self.assertEqual(session.get_decoded(), 19 | {'foo': 'bar', auth.SESSION_KEY: 1}) 20 | 21 | def test_very_long_ua(self): 22 | ua = 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; ' \ 23 | 'Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; ' \ 24 | '.NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; ' \ 25 | 'InfoPath.3; ms-office; MSOffice 14)' 26 | store = SessionStore(user_agent=ua, ip='127.0.0.1') 27 | store.save() 28 | 29 | session = Session.objects.get(pk=store.session_key) 30 | self.assertEqual(session.user_agent, ua[:200]) 31 | -------------------------------------------------------------------------------- /user_sessions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Session', 14 | fields=[ 15 | ( 16 | 'session_key', 17 | models.CharField(max_length=40, serialize=False, verbose_name='session key', primary_key=True) 18 | ), 19 | ('session_data', models.TextField(verbose_name='session data')), 20 | ('expire_date', models.DateTimeField(verbose_name='expiry date', db_index=True)), 21 | ('user_agent', models.CharField(max_length=200)), 22 | ('last_activity', models.DateTimeField(auto_now=True)), 23 | ('ip', models.GenericIPAddressField()), 24 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 25 | ], 26 | options={ 27 | 'verbose_name': 'session', 28 | 'verbose_name_plural': 'sessions', 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | 11 | ## Possible Solution 12 | 13 | 14 | 15 | ## Steps to Reproduce (for bugs) 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 4. 22 | 23 | ## Context 24 | 25 | 26 | 27 | ## Your Environment 28 | 29 | * Browser and version: 30 | * Python version: 31 | * Django version: 32 | * django-otp version: 33 | * django-user-sessions version: 34 | * Link to your project: 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | - [ ] I have added tests to cover my changes. 30 | - [ ] All new and existing tests passed. 31 | -------------------------------------------------------------------------------- /user_sessions/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.base_session import ( 3 | AbstractBaseSession, BaseSessionManager, 4 | ) 5 | from django.db import models 6 | 7 | from .backends.db import SessionStore 8 | 9 | 10 | class SessionManager(BaseSessionManager): 11 | use_in_migrations = True 12 | 13 | 14 | # https://docs.djangoproject.com/en/4.2/topics/http/sessions/#extending-database-backed-session-engines 15 | class Session(AbstractBaseSession): 16 | """ 17 | Session objects containing user session information. 18 | 19 | Django provides full support for anonymous sessions. The session 20 | framework lets you store and retrieve arbitrary data on a 21 | per-site-visitor basis. It stores data on the server side and 22 | abstracts the sending and receiving of cookies. Cookies contain a 23 | session ID -- not the data itself. 24 | 25 | Additionally this session object provides the following properties: 26 | ``user``, ``user_agent`` and ``ip``. 27 | """ 28 | user = models.ForeignKey(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), 29 | null=True, on_delete=models.CASCADE) 30 | user_agent = models.CharField(null=True, blank=True, max_length=200) 31 | last_activity = models.DateTimeField(auto_now=True) 32 | ip = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP') 33 | 34 | objects = SessionManager() 35 | 36 | # Used in get_decoded 37 | @classmethod 38 | def get_session_store_class(cls): 39 | return SessionStore 40 | -------------------------------------------------------------------------------- /user_sessions/templates/user_sessions/session_list.html: -------------------------------------------------------------------------------- 1 | {% extends "user_sessions/_base.html" %} 2 | {% load user_sessions i18n %} 3 | 4 | {% block content %} 5 | {% trans "unknown on unknown" as unknown_on_unknown %} 6 | {% trans "unknown" as unknown %} 7 | 8 |

{% trans "Active Sessions" %}

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for object in object_list %} 19 | 20 | 21 | 22 | 29 | 30 | {% endfor %} 31 |
{% trans "Location" %}{% trans "Device" %}{% trans "Last Activity" %}
{{ object.ip|location|default_if_none:unknown|safe }} ({{ object.ip }}){{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }} 23 | {% if object.session_key == session_key %} 24 | {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} 25 | {% else %} 26 | {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} 27 | {% endif %} 28 |
32 | 33 | {% if object_list.count > 1 %} 34 |
35 | {% csrf_token %} 36 |

{% blocktrans %}You can also end all other sessions but the current. 37 | This will log you out on all other devices.{% endblocktrans %}

38 | 39 |
40 | {% endif %} 41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib.auth.models import User 4 | from django.core.management import call_command 5 | from django.test import TestCase, TransactionTestCase 6 | from django.test.utils import modify_settings 7 | from django.utils.timezone import now 8 | 9 | from user_sessions.models import Session 10 | 11 | 12 | class ClearsessionsCommandTest(TestCase): 13 | def test_can_call(self): 14 | Session.objects.create(expire_date=now() - timedelta(days=1), 15 | ip='127.0.0.1') 16 | call_command('clearsessions') 17 | self.assertEqual(Session.objects.count(), 0) 18 | 19 | 20 | class MigratesessionsCommandTest(TransactionTestCase): 21 | @modify_settings(INSTALLED_APPS={'append': 'django.contrib.sessions'}) 22 | def test_migrate_from_login(self): 23 | from django.contrib.sessions.backends.db import ( 24 | SessionStore as DjangoSessionStore, 25 | ) 26 | from django.contrib.sessions.models import Session as DjangoSession 27 | try: 28 | call_command('migrate', 'sessions') 29 | call_command('clearsessions') 30 | user = User.objects.create_user('bouke', '', 'secret') 31 | session = DjangoSessionStore() 32 | session['_auth_user_id'] = user.id 33 | session.save() 34 | self.assertEqual(Session.objects.count(), 0) 35 | self.assertEqual(DjangoSession.objects.count(), 1) 36 | call_command('migratesessions') 37 | new_sessions = list(Session.objects.all()) 38 | self.assertEqual(len(new_sessions), 1) 39 | self.assertEqual(new_sessions[0].user, user) 40 | self.assertEqual(new_sessions[0].ip, '127.0.0.1') 41 | finally: 42 | call_command('migrate', 'sessions', 'zero') 43 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Current session 5 | --------------- 6 | The current session is available on the request, just like the normal session 7 | middleware makes the session available:: 8 | 9 | def my_view(request): 10 | request.session 11 | 12 | 13 | All sessions 14 | ------------ 15 | To get the list of a user's sessions:: 16 | 17 | sessions = user.session_set.filter(expire_date__gt=now()) 18 | 19 | You could logout the user everywhere:: 20 | 21 | user.session_set.all().delete() 22 | 23 | 24 | Generic views 25 | ------------- 26 | There are two views included with this application, 27 | :class:`~user_sessions.views.SessionListView` and 28 | :class:`~user_sessions.views.SessionDeleteView`. Using this views you have a 29 | simple, but effective, user session management that even looks great out of 30 | the box: 31 | 32 | .. image:: _static/custom-view.png 33 | 34 | Template tags 35 | ~~~~~~~~~~~~~ 36 | 37 | - ``browser`` - used to get just 38 | the browser from a session 39 | - ``platform`` - used to get just 40 | the operating system from a session 41 | - ``device`` - used to get both 42 | the user's browser and the operating system from a session 43 | 44 | .. code-block:: html+django 45 | 46 | {% load user_sessions %} 47 | {{ session.user_agent|device }} -> Safari on macOS 48 | {{ session.user_agent|browser }} -> Safari 49 | {{ session.user_agent|platform }} -> macOS 50 | 51 | - ``location`` - used to show an 52 | approximate location of the last IP address for a session 53 | 54 | .. code-block:: html+django 55 | 56 | {% load user_sessions %} 57 | {{ session.ip|location }} -> Zwolle, The Netherlands 58 | 59 | 60 | Admin views 61 | ----------- 62 | 63 | The user's IP address and user agent are also stored on the session. This 64 | allows to show a list of active sessions to the user in the admin: 65 | 66 | .. image:: _static/admin-view.png 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | max-parallel: 5 12 | matrix: 13 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12-dev'] 14 | django-version: ['3.2', '4.2', 'main'] 15 | exclude: 16 | - python-version: '3.11' 17 | django-version: '3.2' 18 | - python-version: '3.12-dev' 19 | django-version: '3.2' 20 | 21 | - python-version: '3.11' 22 | django-version: '4.0' 23 | - python-version: '3.12-dev' 24 | django-version: '4.0' 25 | 26 | - python-version: '3.12-dev' 27 | django-version: '4.1' 28 | 29 | - python-version: '3.8' 30 | django-version: 'main' 31 | - python-version: '3.9' 32 | django-version: 'main' 33 | - python-version: '3.10' 34 | django-version: 'main' 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | cache: pip 44 | cache-dependency-path: pyproject.toml 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | python -m pip install --upgrade tox tox-gh-actions 50 | 51 | - name: Tox tests 52 | continue-on-error: ${{ endsWith(matrix.python-version, '-dev') || matrix.django-version == 'main' }} 53 | run: | 54 | tox -v 55 | env: 56 | DJANGO: ${{ matrix.django-version }} 57 | 58 | - name: Upload coverage 59 | uses: codecov/codecov-action@v3 60 | with: 61 | name: Python ${{ matrix.python-version }} 62 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.contrib.auth.models import User 4 | from django.conf import settings 5 | from django.test import TestCase 6 | from django.test.utils import override_settings 7 | 8 | from user_sessions.backends.db import SessionStore 9 | 10 | from .utils import Client 11 | 12 | 13 | class ClientTest(TestCase): 14 | def test_invalid_login(self): 15 | client = Client() 16 | self.assertFalse(client.login()) 17 | 18 | def test_restore_session(self): 19 | store = SessionStore(user_agent='Python/2.7', ip='127.0.0.1') 20 | store['foo'] = 'bar' 21 | store.save() 22 | client = Client() 23 | client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key 24 | User.objects.create_user('bouke', '', 'secret') 25 | assert client.login(username='bouke', password='secret') 26 | self.assertEqual(client.session['foo'], 'bar') 27 | 28 | def test_login_logout(self): 29 | client = Client() 30 | User.objects.create_user('bouke', '', 'secret') 31 | assert client.login(username='bouke', password='secret') 32 | assert settings.SESSION_COOKIE_NAME in client.cookies 33 | 34 | client.logout() 35 | assert settings.SESSION_COOKIE_NAME not in client.cookies 36 | 37 | # should not raise 38 | client.logout() 39 | 40 | @patch('django.contrib.auth.signals.user_logged_in.send') 41 | def test_login_signal(self, mock_user_logged_in): 42 | client = Client() 43 | User.objects.create_user('bouke', '', 'secret') 44 | assert client.login(username='bouke', password='secret') 45 | assert mock_user_logged_in.called 46 | request = mock_user_logged_in.call_args[1]['request'] 47 | assert getattr(request, 'user', None) is not None 48 | 49 | @override_settings(INSTALLED_APPS=()) 50 | def test_no_session(self): 51 | self.assertIsNone(Client().session) 52 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.conf import settings 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from user_sessions.models import Session 7 | 8 | 9 | class MiddlewareTest(TestCase): 10 | def test_unmodified_session(self): 11 | self.client.get('/', HTTP_USER_AGENT='Python/2.7') 12 | self.assertNotIn(settings.SESSION_COOKIE_NAME, self.client.cookies) 13 | 14 | def test_modify_session(self): 15 | self.client.get('/modify_session/', HTTP_USER_AGENT='Python/2.7') 16 | self.assertIn(settings.SESSION_COOKIE_NAME, self.client.cookies) 17 | session = Session.objects.get( 18 | pk=self.client.cookies[settings.SESSION_COOKIE_NAME].value 19 | ) 20 | self.assertEqual(session.user_agent, 'Python/2.7') 21 | self.assertEqual(session.ip, '127.0.0.1') 22 | 23 | def test_login(self): 24 | admin_login_url = reverse('admin:login') 25 | user = User.objects.create_superuser('bouke', '', 'secret') 26 | response = self.client.post(admin_login_url, 27 | data={ 28 | 'username': 'bouke', 29 | 'password': 'secret', 30 | 'this_is_the_login_form': '1', 31 | 'next': '/admin/'}, 32 | HTTP_USER_AGENT='Python/2.7') 33 | self.assertRedirects(response, '/admin/') 34 | session = Session.objects.get( 35 | pk=self.client.cookies[settings.SESSION_COOKIE_NAME].value 36 | ) 37 | self.assertEqual( 38 | self.client.cookies[settings.SESSION_COOKIE_NAME]["samesite"], 39 | settings.SESSION_COOKIE_SAMESITE, 40 | ) 41 | self.assertEqual(user, session.user) 42 | 43 | def test_long_ua(self): 44 | self.client.get('/modify_session/', 45 | HTTP_USER_AGENT=''.join('a' for _ in range(400))) 46 | -------------------------------------------------------------------------------- /user_sessions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | from django.utils.timezone import now 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from user_sessions.templatetags.user_sessions import device, location 7 | 8 | from .models import Session 9 | 10 | 11 | class ExpiredFilter(admin.SimpleListFilter): 12 | title = _('Is Valid') 13 | parameter_name = 'active' 14 | 15 | def lookups(self, request, model_admin): 16 | return ( 17 | ('1', _('Active')), 18 | ('0', _('Expired')) 19 | ) 20 | 21 | def queryset(self, request, queryset): 22 | if self.value() == '1': 23 | return queryset.filter(expire_date__gt=now()) 24 | elif self.value() == '0': 25 | return queryset.filter(expire_date__lte=now()) 26 | 27 | 28 | class OwnerFilter(admin.SimpleListFilter): 29 | title = _('Owner') 30 | parameter_name = 'owner' 31 | 32 | def lookups(self, request, model_admin): 33 | return ( 34 | ('my', _('Self')), 35 | ) 36 | 37 | def queryset(self, request, queryset): 38 | if self.value() == 'my': 39 | return queryset.filter(user=request.user) 40 | 41 | 42 | class SessionAdmin(admin.ModelAdmin): 43 | list_display = 'ip', 'user', 'is_valid', 'location', 'device', 44 | search_fields = () 45 | list_filter = ExpiredFilter, OwnerFilter 46 | raw_id_fields = 'user', 47 | exclude = 'session_key', 48 | list_select_related = ['user'] 49 | 50 | def get_search_fields(self, request): 51 | User = get_user_model() 52 | return ('ip', f"user__{getattr(User, 'USERNAME_FIELD', 'username')}") 53 | 54 | def is_valid(self, obj): 55 | return obj.expire_date > now() 56 | is_valid.boolean = True 57 | 58 | def location(self, obj): 59 | return location(obj.ip) 60 | 61 | def device(self, obj): 62 | return device(obj.user_agent) if obj.user_agent else '' 63 | 64 | 65 | admin.site.register(Session, SessionAdmin) 66 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 4 | BASE_DIR = Path(__file__).resolve().parent.parent 5 | 6 | SECRET_KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' 7 | 8 | INSTALLED_APPS = [ 9 | 'django.contrib.admin', 10 | 'django.contrib.auth', 11 | 'django.contrib.contenttypes', 12 | 'user_sessions', 13 | 'tests', 14 | ] 15 | 16 | MIDDLEWARE = ( 17 | 'user_sessions.middleware.SessionMiddleware', 18 | 'django.middleware.common.CommonMiddleware', 19 | 'django.middleware.csrf.CsrfViewMiddleware', 20 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 21 | ) 22 | 23 | ROOT_URLCONF = 'tests.urls' 24 | 25 | CACHES = { 26 | 'default': { 27 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 28 | } 29 | } 30 | 31 | DATABASES = { 32 | 'default': { 33 | 'ENGINE': 'django.db.backends.sqlite3', 34 | 'NAME': ':memory:', 35 | } 36 | } 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'DIRS': [ 42 | BASE_DIR / 'templates', 43 | ], 44 | 'APP_DIRS': True, 45 | 'OPTIONS': { 46 | 'context_processors': [ 47 | 'django.contrib.auth.context_processors.auth', 48 | 'django.template.context_processors.debug', 49 | 'django.template.context_processors.i18n', 50 | 'django.template.context_processors.media', 51 | 'django.template.context_processors.request', 52 | 'django.template.context_processors.static', 53 | 'django.template.context_processors.tz', 54 | 'django.contrib.messages.context_processors.messages', 55 | ], 56 | }, 57 | }, 58 | ] 59 | 60 | GEOIP_PATH = BASE_DIR / 'tests' 61 | GEOIP_CITY = 'test_city.mmdb' 62 | GEOIP_COUNTRY = 'test_country.mmdb' 63 | 64 | SESSION_ENGINE = 'user_sessions.backends.db' 65 | 66 | LOGIN_URL = '/admin/' 67 | LOGOUT_REDIRECT_URL = '/' 68 | 69 | SILENCED_SYSTEM_CHECKS = ['admin.E406', 'admin.E409', 'admin.E410'] 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] 3 | 4 | [project] 5 | name = "django-user-sessions" 6 | authors = [ 7 | {name = "Bouke Haarsma", email = "bouke@haarsma.eu"}, 8 | ] 9 | description = "Django sessions with a foreign key to the user" 10 | readme = "README.rst" 11 | requires-python = ">=3.8" 12 | keywords = ["django", "sessions"] 13 | license = {text = "MIT"} 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Web Environment", 17 | "Framework :: Django", 18 | "Framework :: Django :: 3.2", 19 | "Framework :: Django :: 4.2", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Topic :: Security", 31 | ] 32 | dependencies = [ 33 | "Django>=3.2", 34 | ] 35 | dynamic = ["version"] 36 | 37 | [project.urls] 38 | homepage = "https://github.com/jazzband/django-user-sessions" 39 | download = "https://pypi.org/project/django-user-sessions/" 40 | documentation = "https://django-user-sessions.readthedocs.io/en/stable/" 41 | changelog = "https://django-user-sessions.readthedocs.io/en/stable/release-notes.html" 42 | issues = "https://github.com/jazzband/django-user-sessions/issues" 43 | 44 | [project.optional-dependencies] 45 | dev = [ 46 | # Example app 47 | "django-debug-toolbar", 48 | # Testing 49 | "coverage", 50 | "tox", 51 | "tox-pyenv", 52 | "detox", 53 | # Transifex 54 | "transifex-client", 55 | # Documentation 56 | "Sphinx", 57 | "sphinx_rtd_theme", 58 | # Build 59 | "bumpversion", 60 | "twine", 61 | ] 62 | 63 | [tool.ruff] 64 | exclude = ["user_sessions/migrations/*.py"] 65 | line-length = 100 66 | 67 | [tool.setuptools] 68 | packages = ["user_sessions"] 69 | 70 | [tool.setuptools_scm] 71 | version_scheme = "post-release" 72 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 1. ``pip install django-user-sessions`` 4 | 2. In ``INSTALLED_APPS`` replace ``'django.contrib.sessions'`` with 5 | ``'user_sessions'``. 6 | 3. In ``MIDDLEWARE`` or ``MIDDLEWARE_CLASSES`` replace 7 | ``'django.contrib.sessions.middleware.SessionMiddleware'`` with 8 | ``'user_sessions.middleware.SessionMiddleware'``. 9 | 4. Add ``SESSION_ENGINE = 'user_sessions.backends.db'``. 10 | 5. Add ``url(r'', include('user_sessions.urls', 'user_sessions')),`` to your 11 | ``urls.py``. 12 | 6. Make sure ``LOGOUT_REDIRECT_URL`` is set to some page to redirect users 13 | after logging out. 14 | 7. Run ``python manage.py syncdb`` (or ``migrate``) and browse to 15 | ``/account/sessions/``. 16 | 17 | System check framework 18 | ---------------------- 19 | 20 | Django warns you about common configuration errors. When replacing the session 21 | middleware with the one provided by this library, it'll start warning about 22 | `admin.E410`. You can silence this warning by adding the following line in 23 | your settings file: 24 | 25 | ``SILENCED_SYSTEM_CHECKS = ['admin.E410']`` 26 | 27 | 28 | GeoIP 29 | ----- 30 | You need to setup GeoIP for the location detection to work. See the Django 31 | documentation on `installing GeoIP`_. 32 | 33 | IP when behind a proxy 34 | ---------------------- 35 | If you're running Django behind a proxy like nginx, you will have to set 36 | the `REMOTE_ADDR` META header manually using a middleware, to stop it from 37 | always returning the ip of the proxy (e.g. 127.0.0.1 in many cases). 38 | 39 | An example middleware to fix this issue is `django-xforwardedfor-middleware`_ 40 | which simply does this for each request: 41 | 42 | ``request.META['REMOTE_ADDR'] = request.META['HTTP_X_FORWARDED_FOR'].split(',')[0].strip()`` 43 | 44 | Your particular configuration may vary, `X-Forwarded-For` must be set by 45 | a proxy that you have control over, otherwise it might be spoofed by the 46 | client. 47 | 48 | .. _installing GeoIP: 49 | https://docs.djangoproject.com/en/1.11/ref/contrib/gis/geoip2/ 50 | 51 | .. _django-xforwardedfor-middleware: 52 | https://github.com/allo-/django-xforwardedfor-middleware 53 | -------------------------------------------------------------------------------- /user_sessions/backends/db.py: -------------------------------------------------------------------------------- 1 | from django.contrib import auth 2 | from django.contrib.sessions.backends.db import SessionStore as DBStore 3 | 4 | 5 | class SessionStore(DBStore): 6 | """ 7 | Implements database session store. 8 | """ 9 | def __init__(self, session_key=None, user_agent=None, ip=None): 10 | super().__init__(session_key) 11 | # Truncate user_agent string to max_length of the CharField 12 | self.user_agent = user_agent[:200] if user_agent else user_agent 13 | self.ip = ip 14 | self.user_id = None 15 | 16 | # Used by superclass to get self.model, which is used elsewhere 17 | @classmethod 18 | def get_model_class(cls): 19 | # Avoids a circular import and allows importing SessionStore when 20 | # user_sessions is not in INSTALLED_APPS 21 | from ..models import Session 22 | 23 | return Session 24 | 25 | def __setitem__(self, key, value): 26 | if key == auth.SESSION_KEY: 27 | self.user_id = value 28 | super().__setitem__(key, value) 29 | 30 | # Used in DBStore.load() 31 | def _get_session_from_db(self): 32 | s = super()._get_session_from_db() 33 | self.user_id = s.user_id 34 | # do not overwrite user_agent/ip, as those might have been updated 35 | if self.user_agent != s.user_agent or self.ip != s.ip: 36 | self.modified = True 37 | return s 38 | 39 | def create(self): 40 | super().create() 41 | self._session_cache = {} 42 | 43 | # Used in DBStore.save() 44 | def create_model_instance(self, data): 45 | """ 46 | Return a new instance of the session model object, which represents the 47 | current session state. Intended to be used for saving the session data 48 | to the database. 49 | """ 50 | return self.model( 51 | session_key=self._get_or_create_session_key(), 52 | session_data=self.encode(data), 53 | expire_date=self.get_expiry_date(), 54 | user_agent=self.user_agent, 55 | user_id=self.user_id, 56 | ip=self.ip, 57 | ) 58 | 59 | def clear(self): 60 | super().clear() 61 | self.user_id = None 62 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | 5 | PROJECT_PATH = os.path.dirname(__file__) 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': os.path.join(PROJECT_PATH, 'database.sqlite3'), 11 | } 12 | } 13 | 14 | STATIC_URL = '/static/' 15 | 16 | SECRET_KEY = 'DO NOT USE THIS KEY!' 17 | 18 | MIDDLEWARE = ( 19 | 'example.middleware.SetRemoteAddrFromForwardedFor', 20 | 'user_sessions.middleware.SessionMiddleware', 21 | 'django.middleware.common.CommonMiddleware', 22 | 'django.middleware.csrf.CsrfViewMiddleware', 23 | 'django.middleware.locale.LocaleMiddleware', 24 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 25 | 'django.contrib.messages.middleware.MessageMiddleware', 26 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 27 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 28 | ) 29 | 30 | ROOT_URLCONF = 'example.urls' 31 | 32 | TEMPLATES = [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'DIRS': [ 36 | os.path.join(PROJECT_PATH, 'templates'), 37 | ], 38 | 'APP_DIRS': True, 39 | 'OPTIONS': { 40 | 'context_processors': [ 41 | 'django.contrib.auth.context_processors.auth', 42 | 'django.template.context_processors.debug', 43 | 'django.template.context_processors.i18n', 44 | 'django.template.context_processors.media', 45 | 'django.template.context_processors.static', 46 | 'django.template.context_processors.tz', 47 | 'django.contrib.messages.context_processors.messages', 48 | ], 49 | 'debug': True, 50 | }, 51 | }, 52 | ] 53 | 54 | INSTALLED_APPS = ( 55 | 'django.contrib.admin', 56 | 'django.contrib.auth', 57 | 'django.contrib.contenttypes', 58 | # 'django.contrib.sessions', 59 | 'django.contrib.messages', 60 | 'django.contrib.staticfiles', 61 | 'user_sessions', 62 | 'debug_toolbar', 63 | ) 64 | 65 | 66 | # Custom configuration 67 | 68 | ALLOWED_HOSTS = ['*'] 69 | 70 | DEBUG_TOOLBAR_CONFIG = { 71 | 'SHOW_TOOLBAR_CALLBACK': lambda request: True, 72 | } 73 | 74 | SESSION_ENGINE = 'user_sessions.backends.db' 75 | 76 | GEOIP_PATH = os.path.join(os.path.dirname(PROJECT_PATH), 'GeoLite2-City.mmdb') 77 | 78 | LOGIN_URL = '/admin/' 79 | LOGOUT_REDIRECT_URL = '/admin/' 80 | 81 | SILENCED_SYSTEM_CHECKS = ['admin.E410'] 82 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from django.utils.timezone import now 8 | 9 | from .utils import Client 10 | 11 | 12 | class ViewsTest(TestCase): 13 | client_class = Client 14 | 15 | def setUp(self): 16 | self.user = User.objects.create_user('bouke', '', 'secret') 17 | assert self.client.login(username='bouke', password='secret') 18 | 19 | def test_list(self): 20 | self.user.session_set.create(session_key='ABC123', ip='127.0.0.1', 21 | expire_date=now() + timedelta(days=1), 22 | user_agent='Firefox') 23 | with self.assertWarnsRegex(UserWarning, r"The address 127\.0\.0\.1 is not in the database"): 24 | response = self.client.get(reverse('user_sessions:session_list')) 25 | self.assertContains(response, 'Active Sessions') 26 | self.assertContains(response, 'Firefox') 27 | self.assertNotContains(response, 'ABC123') 28 | 29 | def test_delete(self): 30 | session_key = self.client.cookies[settings.SESSION_COOKIE_NAME].value 31 | response = self.client.post(reverse('user_sessions:session_delete', 32 | args=[session_key])) 33 | self.assertRedirects(response, '/') 34 | 35 | def test_delete_all_other(self): 36 | self.user.session_set.create(ip='127.0.0.1', expire_date=now() + timedelta(days=1)) 37 | self.assertEqual(self.user.session_set.count(), 2) 38 | response = self.client.post(reverse('user_sessions:session_delete_other')) 39 | with self.assertWarnsRegex(UserWarning, r"The address 127\.0\.0\.1 is not in the database"): 40 | self.assertRedirects(response, reverse('user_sessions:session_list')) 41 | self.assertEqual(self.user.session_set.count(), 1) 42 | 43 | def test_delete_some_other(self): 44 | other = self.user.session_set.create(session_key='OTHER', ip='127.0.0.1', 45 | expire_date=now() + timedelta(days=1)) 46 | self.assertEqual(self.user.session_set.count(), 2) 47 | response = self.client.post(reverse('user_sessions:session_delete', 48 | args=[other.session_key])) 49 | with self.assertWarnsRegex(UserWarning, r"The address 127\.0\.0\.1 is not in the database"): 50 | self.assertRedirects(response, reverse('user_sessions:session_list')) 51 | self.assertEqual(self.user.session_set.count(), 1) 52 | -------------------------------------------------------------------------------- /user_sessions/management/commands/migratesessions.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.management.base import BaseCommand 6 | 7 | from user_sessions.models import Session as UserSession 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get_model_class(full_model_name): 13 | try: 14 | old_model_package, old_model_class_name = full_model_name.rsplit('.', 1) 15 | package = importlib.import_module(old_model_package) 16 | return getattr(package, old_model_class_name) 17 | except RuntimeError as e: 18 | if 'INSTALLED_APPS' in e.message: 19 | raise RuntimeError( 20 | "To run this command, temporarily append '{model}' to settings.INSTALLED_APPS" 21 | .format(model=old_model_package.rsplit('.models')[0])) 22 | raise 23 | 24 | 25 | class Command(BaseCommand): 26 | """ 27 | Convert existing (old) sessions to the user_sessions SessionStore. 28 | 29 | If you have an operational site and switch to user_sessions, you might want to keep your 30 | active users logged in. We assume the old sessions are stored in a database table `oldmodel`. 31 | This command creates a `user_session.Session` object for each session of the previous model. 32 | """ 33 | def add_arguments(self, parser): 34 | parser.add_argument( 35 | '--oldmodel', 36 | dest='oldmodel', 37 | default='django.contrib.sessions.models.Session', 38 | help='Existing session model to migrate to the new UserSessions database table' 39 | ) 40 | 41 | def handle(self, *args, **options): 42 | User = get_user_model() 43 | old_sessions = get_model_class(options['oldmodel']).objects.all() 44 | logger.info("Processing %d session objects" % old_sessions.count()) 45 | conversion_count = 0 46 | for old_session in old_sessions: 47 | if not UserSession.objects.filter(session_key=old_session.session_key).exists(): 48 | data = old_session.get_decoded() 49 | user = None 50 | if '_auth_user_id' in data: 51 | user = User.objects.filter(pk=data['_auth_user_id']).first() 52 | UserSession.objects.create( 53 | session_key=old_session.session_key, 54 | session_data=old_session.session_data, 55 | expire_date=old_session.expire_date, 56 | user=user, 57 | ip='127.0.0.1' 58 | ) 59 | conversion_count += 1 60 | 61 | logger.info("Created %d new session objects" % conversion_count) 62 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | from django.urls import reverse 6 | 7 | from user_sessions.backends.db import SessionStore 8 | 9 | from .utils import Client 10 | 11 | 12 | class AdminTest(TestCase): 13 | client_class = Client 14 | 15 | def setUp(self): 16 | User.objects.create_superuser('bouke', '', 'secret') 17 | assert self.client.login(username='bouke', password='secret') 18 | 19 | expired = SessionStore(user_agent='Python/2.5', ip='20.13.1.1') 20 | expired.set_expiry(-365 * 86400) 21 | expired.save() 22 | unexpired = SessionStore(user_agent='Python/2.7', ip='1.1.1.1') 23 | unexpired.save() 24 | 25 | self.admin_url = reverse('admin:user_sessions_session_changelist') 26 | 27 | def test_list(self): 28 | with self.assertWarnsRegex(UserWarning, r"The address 1\.1\.1\.1 is not in the database"): 29 | response = self.client.get(self.admin_url) 30 | self.assertContains(response, 'Select session to change') 31 | self.assertContains(response, '127.0.0.1') 32 | self.assertContains(response, '20.13.1.1') 33 | self.assertContains(response, '1.1.1.1') 34 | 35 | def test_search(self): 36 | with self.assertWarnsRegex(UserWarning, r"The address 127\.0\.0\.1 is not in the database"): 37 | response = self.client.get(self.admin_url, {'q': 'bouke'}) 38 | self.assertContains(response, '127.0.0.1') 39 | self.assertNotContains(response, '20.13.1.1') 40 | self.assertNotContains(response, '1.1.1.1') 41 | 42 | def test_mine(self): 43 | my_sessions = f"{self.admin_url}?{urlencode({'owner': 'my'})}" 44 | with self.assertWarnsRegex(UserWarning, r"The address 127\.0\.0\.1 is not in the database"): 45 | response = self.client.get(my_sessions) 46 | self.assertContains(response, '127.0.0.1') 47 | self.assertNotContains(response, '1.1.1.1') 48 | 49 | def test_expired(self): 50 | expired = f"{self.admin_url}?{urlencode({'active': '0'})}" 51 | with self.assertWarnsRegex(UserWarning, r"The address 20\.13\.1\.1 is not in the database"): 52 | response = self.client.get(expired) 53 | self.assertContains(response, '20.13.1.1') 54 | self.assertNotContains(response, '1.1.1.1') 55 | 56 | def test_unexpired(self): 57 | unexpired = f"{self.admin_url}?{urlencode({'active': '1'})}" 58 | with self.assertWarnsRegex(UserWarning, r"The address 1\.1\.1\.1 is not in the database"): 59 | response = self.client.get(unexpired) 60 | self.assertContains(response, '1.1.1.1') 61 | self.assertNotContains(response, '20.13.1.1') 62 | -------------------------------------------------------------------------------- /user_sessions/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import logout 3 | from django.contrib.auth.decorators import login_required 4 | from django.shortcuts import redirect, resolve_url 5 | from django.urls import reverse_lazy 6 | from django.utils.decorators import method_decorator 7 | from django.utils.timezone import now 8 | from django.views.generic import ListView, View 9 | from django.views.generic.detail import BaseDetailView 10 | from django.views.generic.edit import DeletionMixin 11 | 12 | 13 | class SessionMixin: 14 | def get_queryset(self): 15 | return self.request.user.session_set\ 16 | .filter(expire_date__gt=now()).order_by('-last_activity') 17 | 18 | 19 | class LoginRequiredMixin: 20 | @method_decorator(login_required) 21 | def dispatch(self, request, *args, **kwargs): 22 | return super().dispatch(request, *args, **kwargs) 23 | 24 | 25 | class SessionListView(LoginRequiredMixin, SessionMixin, ListView): 26 | """ 27 | View for listing a user's own sessions. 28 | 29 | This view shows list of a user's currently active sessions. You can 30 | override the template by providing your own template at 31 | `user_sessions/session_list.html`. 32 | """ 33 | def get_context_data(self, **kwargs): 34 | kwargs['session_key'] = self.request.session.session_key 35 | return super().get_context_data(**kwargs) 36 | 37 | 38 | class SessionDeleteView(LoginRequiredMixin, SessionMixin, DeletionMixin, BaseDetailView): 39 | """ 40 | View for deleting a user's own session. 41 | 42 | This view allows a user to delete an active session. For example log 43 | out a session from a computer at the local library or a friend's place. 44 | """ 45 | def delete(self, request, *args, **kwargs): 46 | if kwargs['pk'] == request.session.session_key: 47 | logout(request) 48 | next_page = getattr(settings, 'LOGOUT_REDIRECT_URL', '/') 49 | return redirect(resolve_url(next_page)) 50 | return super().delete(request, *args, **kwargs) 51 | 52 | def get_success_url(self): 53 | return str(reverse_lazy('user_sessions:session_list')) 54 | 55 | 56 | class SessionDeleteOtherView(LoginRequiredMixin, SessionMixin, DeletionMixin, View): 57 | """ 58 | View for deleting all user's sessions but the current. 59 | 60 | This view allows a user to delete all other active session. For example 61 | log out all sessions from a computer at the local library or a friend's 62 | place. 63 | """ 64 | def get_object(self): 65 | return super().get_queryset().\ 66 | exclude(session_key=self.request.session.session_key) 67 | 68 | def get_success_url(self): 69 | return str(reverse_lazy('user_sessions:session_list')) 70 | -------------------------------------------------------------------------------- /docs/release-notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | 2.0.0 5 | ---------- 6 | * New: Support for Django 3.2 and 4.0 7 | * Dropped Django <3.2 support. 8 | * New: Support for Python 3.9 and 3.10. 9 | * Moved CI to GitHub Actions. 10 | * Dropped support for Python 3.5 and 3.6. 11 | * Add detection for Chromium-based Edge browser 12 | * Rename OS X to macOS 13 | * Add detection for newere macOS versions 14 | 15 | 1.7.0 16 | ----- 17 | * new: Support for Django 2.2+. 18 | * Dropped Django <2.2 support. 19 | 20 | 1.6.0 21 | ----- 22 | * New: Support for Django 2.0. 23 | * Dropped Django <1.11 support. 24 | * Command for migrating existing sessions to the new session store (#33). 25 | 26 | 1.5.3 27 | ----- 28 | * Fixed issue with incorrect location being displayed. 29 | 30 | 1.5.2 31 | ----- 32 | * Also work with GeoIP2 country database. 33 | 34 | 1.5.1 35 | ----- 36 | * Updated documentation for GeoIP2 library. 37 | * Correctly detect macOS version on Firefox. 38 | 39 | 1.5.0 40 | ----- 41 | * Added Django 1.11 support. 42 | * Added support for GeoIP2 library. 43 | * Added detection of Windows 10 and macOS from user-agent. 44 | * Fixed #73 -- Error when deleting individual session from list view. 45 | * Fixed #74 -- user agent not being shown in list view. 46 | * Resolved Django’s deprecation warnings (preliminary Django 2.0 support). 47 | * Make templatetags return None instead of 'unknown', provide your own fallback 48 | value with `default_if_none:`. 49 | * Allow translation of fallback values. 50 | 51 | 1.4.0 52 | ----- 53 | * Added Django Channels support. 54 | * Fixed #62 -- Provide request.user in signals. 55 | * Ending current session will logout instead, make sure LOGOUT_REDIRECT_URL is 56 | set. 57 | 58 | 1.3.1 59 | ----- 60 | * Added Django 1.10 support. 61 | 62 | 1.3.0 63 | ----- 64 | * Added Django 1.9 support. 65 | * Dropped support for Django 1.7 and below. 66 | 67 | 1.2.0 68 | ----- 69 | * New feature: delete all-but-current sessions. 70 | * Added clearsessions command. 71 | 72 | 1.1.1 73 | ----- 74 | * Added Django 1.8 support. 75 | 76 | 1.1.0 77 | ----- 78 | * Fixed #14 -- Truncate long user agents strings. 79 | * Fixed #23 -- Cannot use admin view search. 80 | * Added Django 1.7 migrations. 81 | 82 | 1.0.0 83 | ----- 84 | * #8 -- Consistent URL patterns. 85 | * #11 -- Support Django 1.6's `ATOMIC_REQUESTS`. 86 | * German translation added. 87 | 88 | 0.1.4 89 | ----- 90 | * Python 3.4 support. 91 | * Django 1.7 (beta) support. 92 | * Italian translation added. 93 | * Chinese translation added. 94 | * Arabic translation updated. 95 | 96 | 0.1.3 97 | ----- 98 | * Documentation. 99 | * Hebrew translation added. 100 | * Arabic translation added. 101 | * Fixed #3 -- Reset `user_id` on logout. 102 | * Fixed #4 -- Add explicit license text. 103 | 104 | 0.1.2 105 | ----- 106 | * Ship with default templates. 107 | * Added Dutch translation. 108 | 109 | 0.1.1 110 | ----- 111 | * Added South migrations. 112 | 113 | 0.1.0 114 | ----- 115 | * Initial release. 116 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import authenticate, login 3 | from django.http import HttpRequest 4 | from django.test import Client as BaseClient 5 | 6 | from user_sessions.backends.db import SessionStore 7 | 8 | 9 | class Client(BaseClient): 10 | """ 11 | Custom implementation of django.test.Client. 12 | 13 | It is required to perform tests that require to login in sites using 14 | django-user-sessions since its implementation of SessionStore has to 15 | required parameters which is not in concordance with what is expected 16 | from the original Client 17 | """ 18 | def login(self, **credentials): 19 | """ 20 | Sets the Factory to appear as if it has successfully logged into a site. 21 | 22 | Returns True if login is possible; False if the provided credentials 23 | are incorrect, or the user is inactive, or if the sessions framework is 24 | not available. 25 | """ 26 | user = authenticate(**credentials) 27 | if user and user.is_active: 28 | # Create a fake request to store login details. 29 | request = HttpRequest() 30 | request.user = None 31 | if self.session: 32 | request.session = self.session 33 | else: 34 | request.session = SessionStore(user_agent='Python/2.7', ip='127.0.0.1') 35 | login(request, user) 36 | 37 | # Save the session values. 38 | request.session.save() 39 | 40 | # Set the cookie to represent the session. 41 | session_cookie = settings.SESSION_COOKIE_NAME 42 | self.cookies[session_cookie] = request.session.session_key 43 | cookie_data = { 44 | 'max-age': None, 45 | 'path': '/', 46 | 'domain': settings.SESSION_COOKIE_DOMAIN, 47 | 'secure': settings.SESSION_COOKIE_SECURE or None, 48 | 'expires': None, 49 | } 50 | self.cookies[session_cookie].update(cookie_data) 51 | 52 | return True 53 | else: 54 | return False 55 | 56 | def logout(self): 57 | """ 58 | Removes the authenticated user's cookies and session object. 59 | 60 | Causes the authenticated user to be logged out. 61 | """ 62 | session_cookie = self.cookies.get(settings.SESSION_COOKIE_NAME) 63 | if session_cookie: 64 | if self.session: 65 | self.session.delete(session_cookie) 66 | del self.cookies[settings.SESSION_COOKIE_NAME] 67 | 68 | def _session(self): 69 | """ 70 | Obtains the current session variables. 71 | """ 72 | if 'user_sessions' in settings.INSTALLED_APPS: 73 | cookie = self.cookies.get(settings.SESSION_COOKIE_NAME, None) 74 | if cookie: 75 | return SessionStore(session_key=cookie.value, 76 | user_agent='Python/2.7', ip='127.0.0.1') 77 | session = property(_session) 78 | -------------------------------------------------------------------------------- /tests/generate_mmdb.pl: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use MaxMind::DB::Writer::Tree; 4 | 5 | my %city_types = ( 6 | city => 'map', 7 | code => 'utf8_string', 8 | continent => 'map', 9 | country => 'map', 10 | en => 'utf8_string', 11 | is_in_european_union => 'boolean', 12 | iso_code => 'utf8_string', 13 | latitude => 'double', 14 | location => 'map', 15 | longitude => 'double', 16 | metro_code => 'utf8_string', 17 | names => 'map', 18 | postal => 'map', 19 | subdivisions => ['array', 'map'], 20 | region => 'utf8_string', 21 | time_zone => 'utf8_string', 22 | ); 23 | 24 | my $city_tree = MaxMind::DB::Writer::Tree->new( 25 | ip_version => 6, 26 | record_size => 24, 27 | database_type => 'GeoLite2-City', 28 | languages => ['en'], 29 | description => { en => 'Test database of IP city data' }, 30 | map_key_type_callback => sub { $city_types{ $_[0] } }, 31 | ); 32 | 33 | $city_tree->insert_network( 34 | '44.55.66.77/32', 35 | { 36 | city => { names => {en => 'San Diego'} }, 37 | continent => { code => 'NA', names => {en => 'North America'} }, 38 | country => { iso_code => 'US', names => {en => 'United States'} }, 39 | is_in_european_union => false, 40 | location => { 41 | latitude => 37.751, 42 | longitude => -97.822, 43 | metro_code => 'custom metro code', 44 | time_zone => 'America/Los Angeles', 45 | }, 46 | postal => { code => 'custom postal code' }, 47 | subdivisions => [ 48 | { iso_code => 'ABC', names => {en => 'Absolute Basic Class'} }, 49 | ], 50 | }, 51 | ); 52 | 53 | my $outfile = ($ENV{'DATA_DIR'} || '/data/') . ($ENV{'CITY_FILENAME'} || 'test_city.mmdb'); 54 | open my $fh, '>:raw', $outfile; 55 | $city_tree->write_tree($fh); 56 | 57 | 58 | 59 | my %country_types = ( 60 | country => 'map', 61 | iso_code => 'utf8_string', 62 | names => 'map', 63 | en => 'utf8_string', 64 | ); 65 | 66 | my $country_tree = MaxMind::DB::Writer::Tree->new( 67 | ip_version => 6, 68 | record_size => 24, 69 | database_type => 'GeoLite2-Country', 70 | languages => ['en'], 71 | description => { en => 'Test database of IP country data' }, 72 | map_key_type_callback => sub { $country_types{ $_[0] } }, 73 | ); 74 | 75 | $country_tree->insert_network( 76 | '8.8.8.8/32', 77 | { 78 | country => { 79 | iso_code => 'US', 80 | names => { 81 | en => 'United States', 82 | }, 83 | }, 84 | }, 85 | ); 86 | 87 | my $outfile = ($ENV{'DATA_DIR'} || '/data/') . ($ENV{'COUNTRY_FILENAME'} || 'test_country.mmdb'); 88 | open my $fh, '>:raw', $outfile; 89 | $country_tree->write_tree($fh); 90 | -------------------------------------------------------------------------------- /tests/test_sessionstore.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.contrib import auth 4 | from django.contrib.auth.models import User 5 | from django.contrib.sessions.backends.base import CreateError 6 | from django.test import TestCase 7 | from django.utils.timezone import now 8 | 9 | from user_sessions.backends.db import SessionStore 10 | from user_sessions.models import Session 11 | 12 | 13 | class SessionStoreTest(TestCase): 14 | def setUp(self): 15 | self.store = SessionStore(user_agent='Python/2.7', ip='127.0.0.1') 16 | User.objects.create_user('bouke', '', 'secret', id=1) 17 | 18 | def test_untouched_init(self): 19 | self.assertFalse(self.store.modified) 20 | self.assertFalse(self.store.accessed) 21 | 22 | def test_auth_session_key(self): 23 | self.assertFalse(auth.SESSION_KEY in self.store) 24 | self.assertFalse(self.store.modified) 25 | self.assertTrue(self.store.accessed) 26 | 27 | self.store.get(auth.SESSION_KEY) 28 | self.assertFalse(self.store.modified) 29 | 30 | self.store[auth.SESSION_KEY] = 1 31 | self.assertTrue(self.store.modified) 32 | 33 | def test_save(self): 34 | self.store[auth.SESSION_KEY] = 1 35 | self.store.save() 36 | 37 | session = Session.objects.get(pk=self.store.session_key) 38 | self.assertEqual(session.user_agent, 'Python/2.7') 39 | self.assertEqual(session.ip, '127.0.0.1') 40 | self.assertEqual(session.user_id, 1) 41 | self.assertAlmostEqual(now(), session.last_activity, 42 | delta=timedelta(seconds=5)) 43 | 44 | def test_load_unmodified(self): 45 | self.store[auth.SESSION_KEY] = 1 46 | self.store.save() 47 | store2 = SessionStore(session_key=self.store.session_key, 48 | user_agent='Python/2.7', ip='127.0.0.1') 49 | store2.load() 50 | self.assertEqual(store2.user_agent, 'Python/2.7') 51 | self.assertEqual(store2.ip, '127.0.0.1') 52 | self.assertEqual(store2.user_id, 1) 53 | self.assertEqual(store2.modified, False) 54 | 55 | def test_load_modified(self): 56 | self.store[auth.SESSION_KEY] = 1 57 | self.store.save() 58 | store2 = SessionStore(session_key=self.store.session_key, 59 | user_agent='Python/3.3', ip='8.8.8.8') 60 | store2.load() 61 | self.assertEqual(store2.user_agent, 'Python/3.3') 62 | self.assertEqual(store2.ip, '8.8.8.8') 63 | self.assertEqual(store2.user_id, 1) 64 | self.assertEqual(store2.modified, True) 65 | 66 | def test_duplicate_create(self): 67 | s1 = SessionStore(session_key='DUPLICATE', user_agent='Python/2.7', ip='127.0.0.1') 68 | s1.create() 69 | s2 = SessionStore(session_key='DUPLICATE', user_agent='Python/2.7', ip='127.0.0.1') 70 | s2.create() 71 | self.assertNotEqual(s1.session_key, s2.session_key) 72 | 73 | s3 = SessionStore(session_key=s1.session_key, user_agent='Python/2.7', ip='127.0.0.1') 74 | with self.assertRaises(CreateError): 75 | s3.save(must_create=True) 76 | 77 | def test_delete(self): 78 | # not persisted, should just return 79 | self.store.delete() 80 | 81 | # create, then delete 82 | self.store.create() 83 | session_key = self.store.session_key 84 | self.store.delete() 85 | 86 | # non-existing sessions, should not raise 87 | self.store.delete() 88 | self.store.delete(session_key) 89 | 90 | def test_clear(self): 91 | """ 92 | Clearing the session should clear all non-browser information 93 | """ 94 | self.store[auth.SESSION_KEY] = 1 95 | self.store.clear() 96 | self.store.save() 97 | 98 | session = Session.objects.get(pk=self.store.session_key) 99 | self.assertEqual(session.user_id, None) 100 | -------------------------------------------------------------------------------- /user_sessions/templatetags/user_sessions.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | 4 | from django import template 5 | from django.contrib.gis.geoip2 import HAS_GEOIP2 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | register = template.Library() 9 | 10 | BROWSERS = ( 11 | (re.compile('Edg'), _('Edge')), 12 | (re.compile('OPR'), _('Opera')), 13 | (re.compile('Chrome'), _('Chrome')), 14 | (re.compile('Safari'), _('Safari')), 15 | (re.compile('Firefox'), _('Firefox')), 16 | (re.compile('IE'), _('Internet Explorer')), 17 | ) 18 | PLATFORMS = ( 19 | (re.compile('Windows Mobile'), _('Windows Mobile')), 20 | (re.compile('Android'), _('Android')), 21 | (re.compile('Linux'), _('Linux')), 22 | (re.compile('iPhone'), _('iPhone')), 23 | (re.compile('iPad'), _('iPad')), 24 | (re.compile('Mac OS X'), _('macOS')), 25 | (re.compile('Windows'), _('Windows')), 26 | ) 27 | 28 | 29 | @register.filter 30 | def platform(value): 31 | """ 32 | Transform the platform from a User Agent into human readable text. 33 | 34 | Example output: 35 | 36 | * iPhone 37 | * Windows 8.1 38 | * macOS 39 | * Linux 40 | * None 41 | """ 42 | 43 | platform = None 44 | for regex, name in PLATFORMS: 45 | if regex.search(value): 46 | platform = name 47 | break 48 | 49 | return platform 50 | 51 | 52 | @register.filter 53 | def browser(value): 54 | """ 55 | Transform the browser from a User Agent into human readable text. 56 | 57 | Example output: 58 | 59 | * Safari 60 | * Chrome 61 | * Safari 62 | * Firefox 63 | * None 64 | """ 65 | 66 | browser = None 67 | for regex, name in BROWSERS: 68 | if regex.search(value): 69 | browser = name 70 | break 71 | 72 | return browser 73 | 74 | 75 | @register.filter 76 | def device(value): 77 | """ 78 | Transform a User Agent into human readable text. 79 | 80 | Example output: 81 | 82 | * Safari on iPhone 83 | * Chrome on Windows 8.1 84 | * Safari on macOS 85 | * Firefox 86 | * Linux 87 | * None 88 | """ 89 | 90 | browser_ = browser(value) 91 | platform_ = platform(value) 92 | 93 | if browser_ and platform_: 94 | return _('%(browser)s on %(device)s') % { 95 | 'browser': browser_, 96 | 'device': platform_ 97 | } 98 | 99 | if browser_: 100 | return browser_ 101 | 102 | if platform_: 103 | return platform_ 104 | 105 | return None 106 | 107 | 108 | @register.filter 109 | def city(value): 110 | location = geoip() and geoip().city(value) 111 | if location and location['city']: 112 | return location['city'] 113 | return None 114 | 115 | 116 | @register.filter 117 | def country(value): 118 | location = geoip() and geoip().country(value) 119 | if location and location['country_name']: 120 | return location['country_name'] 121 | return None 122 | 123 | 124 | @register.filter 125 | def location(value): 126 | """ 127 | Transform an IP address into an approximate location. 128 | 129 | Example output: 130 | 131 | * Zwolle, The Netherlands 132 | * The Netherlands 133 | * None 134 | """ 135 | try: 136 | location = geoip() and geoip().city(value) 137 | except Exception: 138 | try: 139 | location = geoip() and geoip().country(value) 140 | except Exception as e: 141 | warnings.warn(str(e), stacklevel=2) 142 | location = None 143 | if location and location['country_name']: 144 | if 'city' in location and location['city']: 145 | return f"{location['city']}, {location['country_name']}" 146 | return location['country_name'] 147 | return None 148 | 149 | 150 | _geoip = None 151 | 152 | 153 | def geoip(): 154 | global _geoip 155 | if _geoip is None: 156 | if HAS_GEOIP2: 157 | from django.contrib.gis.geoip2 import GeoIP2 158 | try: 159 | _geoip = GeoIP2() 160 | except Exception as e: 161 | warnings.warn(str(e), stacklevel=2) 162 | return _geoip 163 | -------------------------------------------------------------------------------- /user_sessions/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: admin.py:20 21 | msgid "Is Valid" 22 | msgstr "" 23 | 24 | #: admin.py:25 25 | msgid "Active" 26 | msgstr "" 27 | 28 | #: admin.py:26 29 | msgid "Expired" 30 | msgstr "" 31 | 32 | #: admin.py:37 33 | msgid "Owner" 34 | msgstr "" 35 | 36 | #: admin.py:42 37 | msgid "Self" 38 | msgstr "" 39 | 40 | #: models.py:37 41 | msgid "session key" 42 | msgstr "" 43 | 44 | #: models.py:39 45 | msgid "session data" 46 | msgstr "" 47 | 48 | #: models.py:40 49 | msgid "expiry date" 50 | msgstr "" 51 | 52 | #: models.py:44 53 | msgid "session" 54 | msgstr "" 55 | 56 | #: models.py:45 57 | msgid "sessions" 58 | msgstr "" 59 | 60 | #: templates/user_sessions/session_list.html:5 61 | msgid "unknown on unknown" 62 | msgstr "" 63 | 64 | #: templates/user_sessions/session_list.html:6 65 | msgid "unknown" 66 | msgstr "" 67 | 68 | #: templates/user_sessions/session_list.html:8 69 | msgid "Active Sessions" 70 | msgstr "" 71 | 72 | #: templates/user_sessions/session_list.html:13 73 | msgid "Location" 74 | msgstr "" 75 | 76 | #: templates/user_sessions/session_list.html:14 77 | msgid "Device" 78 | msgstr "" 79 | 80 | #: templates/user_sessions/session_list.html:15 81 | msgid "Last Activity" 82 | msgstr "" 83 | 84 | #: templates/user_sessions/session_list.html:16 85 | #: templates/user_sessions/session_list.html:34 86 | #: templates/user_sessions/session_list.html:36 87 | msgid "End Session" 88 | msgstr "" 89 | 90 | #: templates/user_sessions/session_list.html:25 91 | #, python-format 92 | msgid "%(time)s ago (this session)" 93 | msgstr "" 94 | 95 | #: templates/user_sessions/session_list.html:27 96 | #, python-format 97 | msgid "%(time)s ago" 98 | msgstr "" 99 | 100 | #: templates/user_sessions/session_list.html:47 101 | msgid "" 102 | "You can also end all other sessions but the current.\n" 103 | " This will log you out on all other devices." 104 | msgstr "" 105 | 106 | #: templates/user_sessions/session_list.html:49 107 | msgid "End All Other Sessions" 108 | msgstr "" 109 | 110 | #: templatetags/user_sessions.py:20 111 | msgid "Chrome" 112 | msgstr "" 113 | 114 | #: templatetags/user_sessions.py:21 115 | msgid "Safari" 116 | msgstr "" 117 | 118 | #: templatetags/user_sessions.py:22 119 | msgid "Firefox" 120 | msgstr "" 121 | 122 | #: templatetags/user_sessions.py:23 123 | msgid "Opera" 124 | msgstr "" 125 | 126 | #: templatetags/user_sessions.py:24 127 | msgid "Internet Explorer" 128 | msgstr "" 129 | 130 | #: templatetags/user_sessions.py:27 131 | msgid "Android" 132 | msgstr "" 133 | 134 | #: templatetags/user_sessions.py:28 135 | msgid "Linux" 136 | msgstr "" 137 | 138 | #: templatetags/user_sessions.py:29 139 | msgid "iPhone" 140 | msgstr "" 141 | 142 | #: templatetags/user_sessions.py:30 143 | msgid "iPad" 144 | msgstr "" 145 | 146 | #: templatetags/user_sessions.py:31 147 | msgid "OS X Mavericks" 148 | msgstr "" 149 | 150 | #: templatetags/user_sessions.py:32 151 | msgid "OS X Yosemite" 152 | msgstr "" 153 | 154 | #: templatetags/user_sessions.py:33 155 | msgid "OS X El Capitan" 156 | msgstr "" 157 | 158 | #: templatetags/user_sessions.py:34 159 | msgid "macOS Sierra" 160 | msgstr "" 161 | 162 | #: templatetags/user_sessions.py:35 163 | msgid "OS X" 164 | msgstr "" 165 | 166 | #: templatetags/user_sessions.py:36 167 | msgid "Windows XP" 168 | msgstr "" 169 | 170 | #: templatetags/user_sessions.py:37 171 | msgid "Windows Vista" 172 | msgstr "" 173 | 174 | #: templatetags/user_sessions.py:38 175 | msgid "Windows 7" 176 | msgstr "" 177 | 178 | #: templatetags/user_sessions.py:39 179 | msgid "Windows 8" 180 | msgstr "" 181 | 182 | #: templatetags/user_sessions.py:40 183 | msgid "Windows 8.1" 184 | msgstr "" 185 | 186 | #: templatetags/user_sessions.py:41 187 | msgid "Windows 10" 188 | msgstr "" 189 | 190 | #: templatetags/user_sessions.py:42 191 | msgid "Windows" 192 | msgstr "" 193 | 194 | #: templatetags/user_sessions.py:74 195 | #, python-format 196 | msgid "%(browser)s on %(device)s" 197 | msgstr "" 198 | -------------------------------------------------------------------------------- /user_sessions/locale/pl_PL/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-user-sessions\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 11 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 12 | "Last-Translator: Bouke Haarsma \n" 13 | "Language-Team: Polish (Poland) (http://www.transifex.com/Bouke/django-user-sessions/language/pl_PL/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: pl_PL\n" 18 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" 19 | 20 | #: admin.py:20 21 | msgid "Is Valid" 22 | msgstr "" 23 | 24 | #: admin.py:25 25 | msgid "Active" 26 | msgstr "" 27 | 28 | #: admin.py:26 29 | msgid "Expired" 30 | msgstr "" 31 | 32 | #: admin.py:37 33 | msgid "Owner" 34 | msgstr "" 35 | 36 | #: admin.py:42 37 | msgid "Self" 38 | msgstr "" 39 | 40 | #: models.py:37 41 | msgid "session key" 42 | msgstr "" 43 | 44 | #: models.py:39 45 | msgid "session data" 46 | msgstr "" 47 | 48 | #: models.py:40 49 | msgid "expiry date" 50 | msgstr "" 51 | 52 | #: models.py:44 53 | msgid "session" 54 | msgstr "" 55 | 56 | #: models.py:45 57 | msgid "sessions" 58 | msgstr "" 59 | 60 | #: templates/user_sessions/session_list.html:5 61 | msgid "unknown on unknown" 62 | msgstr "" 63 | 64 | #: templates/user_sessions/session_list.html:6 65 | msgid "unknown" 66 | msgstr "" 67 | 68 | #: templates/user_sessions/session_list.html:8 69 | msgid "Active Sessions" 70 | msgstr "" 71 | 72 | #: templates/user_sessions/session_list.html:13 73 | msgid "Location" 74 | msgstr "" 75 | 76 | #: templates/user_sessions/session_list.html:14 77 | msgid "Device" 78 | msgstr "" 79 | 80 | #: templates/user_sessions/session_list.html:15 81 | msgid "Last Activity" 82 | msgstr "" 83 | 84 | #: templates/user_sessions/session_list.html:16 85 | #: templates/user_sessions/session_list.html:34 86 | #: templates/user_sessions/session_list.html:36 87 | msgid "End Session" 88 | msgstr "" 89 | 90 | #: templates/user_sessions/session_list.html:25 91 | #, python-format 92 | msgid "%(time)s ago (this session)" 93 | msgstr "" 94 | 95 | #: templates/user_sessions/session_list.html:27 96 | #, python-format 97 | msgid "%(time)s ago" 98 | msgstr "" 99 | 100 | #: templates/user_sessions/session_list.html:47 101 | msgid "" 102 | "You can also end all other sessions but the current.\n" 103 | " This will log you out on all other devices." 104 | msgstr "" 105 | 106 | #: templates/user_sessions/session_list.html:49 107 | msgid "End All Other Sessions" 108 | msgstr "" 109 | 110 | #: templatetags/user_sessions.py:20 111 | msgid "Chrome" 112 | msgstr "" 113 | 114 | #: templatetags/user_sessions.py:21 115 | msgid "Safari" 116 | msgstr "" 117 | 118 | #: templatetags/user_sessions.py:22 119 | msgid "Firefox" 120 | msgstr "" 121 | 122 | #: templatetags/user_sessions.py:23 123 | msgid "Opera" 124 | msgstr "" 125 | 126 | #: templatetags/user_sessions.py:24 127 | msgid "Internet Explorer" 128 | msgstr "" 129 | 130 | #: templatetags/user_sessions.py:27 131 | msgid "Android" 132 | msgstr "" 133 | 134 | #: templatetags/user_sessions.py:28 135 | msgid "Linux" 136 | msgstr "" 137 | 138 | #: templatetags/user_sessions.py:29 139 | msgid "iPhone" 140 | msgstr "" 141 | 142 | #: templatetags/user_sessions.py:30 143 | msgid "iPad" 144 | msgstr "" 145 | 146 | #: templatetags/user_sessions.py:31 147 | msgid "OS X Mavericks" 148 | msgstr "" 149 | 150 | #: templatetags/user_sessions.py:32 151 | msgid "OS X Yosemite" 152 | msgstr "" 153 | 154 | #: templatetags/user_sessions.py:33 155 | msgid "OS X El Capitan" 156 | msgstr "" 157 | 158 | #: templatetags/user_sessions.py:34 159 | msgid "macOS Sierra" 160 | msgstr "" 161 | 162 | #: templatetags/user_sessions.py:35 163 | msgid "OS X" 164 | msgstr "" 165 | 166 | #: templatetags/user_sessions.py:36 167 | msgid "Windows XP" 168 | msgstr "" 169 | 170 | #: templatetags/user_sessions.py:37 171 | msgid "Windows Vista" 172 | msgstr "" 173 | 174 | #: templatetags/user_sessions.py:38 175 | msgid "Windows 7" 176 | msgstr "" 177 | 178 | #: templatetags/user_sessions.py:39 179 | msgid "Windows 8" 180 | msgstr "" 181 | 182 | #: templatetags/user_sessions.py:40 183 | msgid "Windows 8.1" 184 | msgstr "" 185 | 186 | #: templatetags/user_sessions.py:41 187 | msgid "Windows 10" 188 | msgstr "" 189 | 190 | #: templatetags/user_sessions.py:42 191 | msgid "Windows" 192 | msgstr "" 193 | 194 | #: templatetags/user_sessions.py:74 195 | #, python-format 196 | msgid "%(browser)s on %(device)s" 197 | msgstr "" 198 | -------------------------------------------------------------------------------- /user_sessions/locale/zh_CN/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # mozillazg , 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-user-sessions\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 13 | "Last-Translator: Bouke Haarsma \n" 14 | "Language-Team: Chinese (China) (http://www.transifex.com/Bouke/django-user-sessions/language/zh_CN/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: zh_CN\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: admin.py:20 22 | msgid "Is Valid" 23 | msgstr "有效的" 24 | 25 | #: admin.py:25 26 | msgid "Active" 27 | msgstr "活跃的" 28 | 29 | #: admin.py:26 30 | msgid "Expired" 31 | msgstr "过期的" 32 | 33 | #: admin.py:37 34 | msgid "Owner" 35 | msgstr "所有者" 36 | 37 | #: admin.py:42 38 | msgid "Self" 39 | msgstr "自身" 40 | 41 | #: models.py:37 42 | msgid "session key" 43 | msgstr "会话 key" 44 | 45 | #: models.py:39 46 | msgid "session data" 47 | msgstr "会话数据" 48 | 49 | #: models.py:40 50 | msgid "expiry date" 51 | msgstr "过期时间" 52 | 53 | #: models.py:44 54 | msgid "session" 55 | msgstr "会话" 56 | 57 | #: models.py:45 58 | msgid "sessions" 59 | msgstr "会话" 60 | 61 | #: templates/user_sessions/session_list.html:5 62 | msgid "unknown on unknown" 63 | msgstr "" 64 | 65 | #: templates/user_sessions/session_list.html:6 66 | msgid "unknown" 67 | msgstr "" 68 | 69 | #: templates/user_sessions/session_list.html:8 70 | msgid "Active Sessions" 71 | msgstr "活跃的会话" 72 | 73 | #: templates/user_sessions/session_list.html:13 74 | msgid "Location" 75 | msgstr "地理位置" 76 | 77 | #: templates/user_sessions/session_list.html:14 78 | msgid "Device" 79 | msgstr "设备" 80 | 81 | #: templates/user_sessions/session_list.html:15 82 | msgid "Last Activity" 83 | msgstr "最近活跃" 84 | 85 | #: templates/user_sessions/session_list.html:16 86 | #: templates/user_sessions/session_list.html:34 87 | #: templates/user_sessions/session_list.html:36 88 | msgid "End Session" 89 | msgstr "结束会话" 90 | 91 | #: templates/user_sessions/session_list.html:25 92 | #, python-format 93 | msgid "%(time)s ago (this session)" 94 | msgstr "%(time)s 前 (当前会话)" 95 | 96 | #: templates/user_sessions/session_list.html:27 97 | #, python-format 98 | msgid "%(time)s ago" 99 | msgstr "%(time)s 前" 100 | 101 | #: templates/user_sessions/session_list.html:47 102 | msgid "" 103 | "You can also end all other sessions but the current.\n" 104 | " This will log you out on all other devices." 105 | msgstr "" 106 | 107 | #: templates/user_sessions/session_list.html:49 108 | msgid "End All Other Sessions" 109 | msgstr "" 110 | 111 | #: templatetags/user_sessions.py:20 112 | msgid "Chrome" 113 | msgstr "Chrome" 114 | 115 | #: templatetags/user_sessions.py:21 116 | msgid "Safari" 117 | msgstr "Safari" 118 | 119 | #: templatetags/user_sessions.py:22 120 | msgid "Firefox" 121 | msgstr "Firefox" 122 | 123 | #: templatetags/user_sessions.py:23 124 | msgid "Opera" 125 | msgstr "Opera" 126 | 127 | #: templatetags/user_sessions.py:24 128 | msgid "Internet Explorer" 129 | msgstr "Internet Explorer" 130 | 131 | #: templatetags/user_sessions.py:27 132 | msgid "Android" 133 | msgstr "Android" 134 | 135 | #: templatetags/user_sessions.py:28 136 | msgid "Linux" 137 | msgstr "Linux" 138 | 139 | #: templatetags/user_sessions.py:29 140 | msgid "iPhone" 141 | msgstr "iPhone" 142 | 143 | #: templatetags/user_sessions.py:30 144 | msgid "iPad" 145 | msgstr "iPad" 146 | 147 | #: templatetags/user_sessions.py:31 148 | msgid "OS X Mavericks" 149 | msgstr "" 150 | 151 | #: templatetags/user_sessions.py:32 152 | msgid "OS X Yosemite" 153 | msgstr "" 154 | 155 | #: templatetags/user_sessions.py:33 156 | msgid "OS X El Capitan" 157 | msgstr "" 158 | 159 | #: templatetags/user_sessions.py:34 160 | msgid "macOS Sierra" 161 | msgstr "" 162 | 163 | #: templatetags/user_sessions.py:35 164 | msgid "OS X" 165 | msgstr "OS X" 166 | 167 | #: templatetags/user_sessions.py:36 168 | msgid "Windows XP" 169 | msgstr "Windows XP" 170 | 171 | #: templatetags/user_sessions.py:37 172 | msgid "Windows Vista" 173 | msgstr "Windows Vista" 174 | 175 | #: templatetags/user_sessions.py:38 176 | msgid "Windows 7" 177 | msgstr "Windows 7" 178 | 179 | #: templatetags/user_sessions.py:39 180 | msgid "Windows 8" 181 | msgstr "Windows 8" 182 | 183 | #: templatetags/user_sessions.py:40 184 | msgid "Windows 8.1" 185 | msgstr "Windows 8.1" 186 | 187 | #: templatetags/user_sessions.py:41 188 | msgid "Windows 10" 189 | msgstr "" 190 | 191 | #: templatetags/user_sessions.py:42 192 | msgid "Windows" 193 | msgstr "Windows" 194 | 195 | #: templatetags/user_sessions.py:74 196 | #, python-format 197 | msgid "%(browser)s on %(device)s" 198 | msgstr "%(browser)s 在 %(device)s 上" 199 | -------------------------------------------------------------------------------- /user_sessions/locale/he/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Kunda, 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-user-sessions\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 13 | "Last-Translator: Bouke Haarsma \n" 14 | "Language-Team: Hebrew (http://www.transifex.com/Bouke/django-user-sessions/language/he/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: he\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: admin.py:20 22 | msgid "Is Valid" 23 | msgstr "תקף" 24 | 25 | #: admin.py:25 26 | msgid "Active" 27 | msgstr "פעיל" 28 | 29 | #: admin.py:26 30 | msgid "Expired" 31 | msgstr "פג תוקף" 32 | 33 | #: admin.py:37 34 | msgid "Owner" 35 | msgstr "בעל" 36 | 37 | #: admin.py:42 38 | msgid "Self" 39 | msgstr "עצמי" 40 | 41 | #: models.py:37 42 | msgid "session key" 43 | msgstr "מפתח אירוע" 44 | 45 | #: models.py:39 46 | msgid "session data" 47 | msgstr "נתוני אירוע" 48 | 49 | #: models.py:40 50 | msgid "expiry date" 51 | msgstr "תאריך תפוגה" 52 | 53 | #: models.py:44 54 | msgid "session" 55 | msgstr "אירוע" 56 | 57 | #: models.py:45 58 | msgid "sessions" 59 | msgstr "אירועים" 60 | 61 | #: templates/user_sessions/session_list.html:5 62 | msgid "unknown on unknown" 63 | msgstr "" 64 | 65 | #: templates/user_sessions/session_list.html:6 66 | msgid "unknown" 67 | msgstr "" 68 | 69 | #: templates/user_sessions/session_list.html:8 70 | msgid "Active Sessions" 71 | msgstr "אירועים פעילים" 72 | 73 | #: templates/user_sessions/session_list.html:13 74 | msgid "Location" 75 | msgstr "מִקוּם" 76 | 77 | #: templates/user_sessions/session_list.html:14 78 | msgid "Device" 79 | msgstr "מכשיר" 80 | 81 | #: templates/user_sessions/session_list.html:15 82 | msgid "Last Activity" 83 | msgstr "פעילות האחרונה" 84 | 85 | #: templates/user_sessions/session_list.html:16 86 | #: templates/user_sessions/session_list.html:34 87 | #: templates/user_sessions/session_list.html:36 88 | msgid "End Session" 89 | msgstr "סיים אירוע" 90 | 91 | #: templates/user_sessions/session_list.html:25 92 | #, python-format 93 | msgid "%(time)s ago (this session)" 94 | msgstr "לפני %(time)s (אירוע זה)" 95 | 96 | #: templates/user_sessions/session_list.html:27 97 | #, python-format 98 | msgid "%(time)s ago" 99 | msgstr "לפני %(time)s" 100 | 101 | #: templates/user_sessions/session_list.html:47 102 | msgid "" 103 | "You can also end all other sessions but the current.\n" 104 | " This will log you out on all other devices." 105 | msgstr "" 106 | 107 | #: templates/user_sessions/session_list.html:49 108 | msgid "End All Other Sessions" 109 | msgstr "" 110 | 111 | #: templatetags/user_sessions.py:20 112 | msgid "Chrome" 113 | msgstr "Google Chrome" 114 | 115 | #: templatetags/user_sessions.py:21 116 | msgid "Safari" 117 | msgstr "Safari" 118 | 119 | #: templatetags/user_sessions.py:22 120 | msgid "Firefox" 121 | msgstr "Firefox" 122 | 123 | #: templatetags/user_sessions.py:23 124 | msgid "Opera" 125 | msgstr "Opera" 126 | 127 | #: templatetags/user_sessions.py:24 128 | msgid "Internet Explorer" 129 | msgstr "Internet Explorer" 130 | 131 | #: templatetags/user_sessions.py:27 132 | msgid "Android" 133 | msgstr "Android" 134 | 135 | #: templatetags/user_sessions.py:28 136 | msgid "Linux" 137 | msgstr "Linux" 138 | 139 | #: templatetags/user_sessions.py:29 140 | msgid "iPhone" 141 | msgstr "iPhone" 142 | 143 | #: templatetags/user_sessions.py:30 144 | msgid "iPad" 145 | msgstr "iPad" 146 | 147 | #: templatetags/user_sessions.py:31 148 | msgid "OS X Mavericks" 149 | msgstr "" 150 | 151 | #: templatetags/user_sessions.py:32 152 | msgid "OS X Yosemite" 153 | msgstr "" 154 | 155 | #: templatetags/user_sessions.py:33 156 | msgid "OS X El Capitan" 157 | msgstr "" 158 | 159 | #: templatetags/user_sessions.py:34 160 | msgid "macOS Sierra" 161 | msgstr "" 162 | 163 | #: templatetags/user_sessions.py:35 164 | msgid "OS X" 165 | msgstr "OS X" 166 | 167 | #: templatetags/user_sessions.py:36 168 | msgid "Windows XP" 169 | msgstr "Windows XP" 170 | 171 | #: templatetags/user_sessions.py:37 172 | msgid "Windows Vista" 173 | msgstr "Windows Vista" 174 | 175 | #: templatetags/user_sessions.py:38 176 | msgid "Windows 7" 177 | msgstr "Windows 7" 178 | 179 | #: templatetags/user_sessions.py:39 180 | msgid "Windows 8" 181 | msgstr "Windows 8" 182 | 183 | #: templatetags/user_sessions.py:40 184 | msgid "Windows 8.1" 185 | msgstr "Windows 8.1" 186 | 187 | #: templatetags/user_sessions.py:41 188 | msgid "Windows 10" 189 | msgstr "" 190 | 191 | #: templatetags/user_sessions.py:42 192 | msgid "Windows" 193 | msgstr "Windows" 194 | 195 | #: templatetags/user_sessions.py:74 196 | #, python-format 197 | msgid "%(browser)s on %(device)s" 198 | msgstr "%(browser)s על %(device)s" 199 | -------------------------------------------------------------------------------- /user_sessions/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: admin.py:20 21 | msgid "Is Valid" 22 | msgstr "Действующий" 23 | 24 | #: admin.py:25 25 | msgid "Active" 26 | msgstr "Активный" 27 | 28 | #: admin.py:26 29 | msgid "Expired" 30 | msgstr "Истекший" 31 | 32 | #: admin.py:37 33 | msgid "Owner" 34 | msgstr "Владелец" 35 | 36 | #: admin.py:42 37 | msgid "Self" 38 | msgstr "Сам" 39 | 40 | #: models.py:37 41 | msgid "session key" 42 | msgstr "ключ сессии" 43 | 44 | #: models.py:39 45 | msgid "session data" 46 | msgstr "сессия" 47 | 48 | #: models.py:40 49 | msgid "expiry date" 50 | msgstr "дата истечения срока" 51 | 52 | #: models.py:44 53 | msgid "session" 54 | msgstr "сессия" 55 | 56 | #: models.py:45 57 | msgid "sessions" 58 | msgstr "сессии" 59 | 60 | #: templates/user_sessions/session_list.html:5 61 | msgid "unknown on unknown" 62 | msgstr "неизвестный из неизвестных" 63 | 64 | #: templates/user_sessions/session_list.html:6 65 | msgid "неизвестный" 66 | msgstr "" 67 | 68 | #: templates/user_sessions/session_list.html:8 69 | msgid "Active Sessions" 70 | msgstr "Активные сессии" 71 | 72 | #: templates/user_sessions/session_list.html:13 73 | msgid "Location" 74 | msgstr "Место расположения" 75 | 76 | #: templates/user_sessions/session_list.html:14 77 | msgid "Device" 78 | msgstr "Устройство" 79 | 80 | #: templates/user_sessions/session_list.html:15 81 | msgid "Last Activity" 82 | msgstr "Последняя активность" 83 | 84 | #: templates/user_sessions/session_list.html:16 85 | #: templates/user_sessions/session_list.html:34 86 | #: templates/user_sessions/session_list.html:36 87 | msgid "End Session" 88 | msgstr "Конец сеанса" 89 | 90 | #: templates/user_sessions/session_list.html:25 91 | #, python-format 92 | msgid "%(time)s ago (this session)" 93 | msgstr "%(time)s назад (сессия)" 94 | 95 | #: templates/user_sessions/session_list.html:27 96 | #, python-format 97 | msgid "%(time)s ago" 98 | msgstr "%(time)s назад" 99 | 100 | #: templates/user_sessions/session_list.html:47 101 | msgid "" 102 | "You can also end all other sessions but the current.\n" 103 | " This will log you out on all other devices." 104 | msgstr "" 105 | "Вы также можете завершить все другие сеансы, кроме текущего.\n" 106 | " Вы выйдете из системы на всех других устройствах.." 107 | 108 | #: templates/user_sessions/session_list.html:49 109 | msgid "Завершить все остальные сеансы" 110 | msgstr "" 111 | 112 | #: templatetags/user_sessions.py:20 113 | msgid "Chrome" 114 | msgstr "" 115 | 116 | #: templatetags/user_sessions.py:21 117 | msgid "Safari" 118 | msgstr "" 119 | 120 | #: templatetags/user_sessions.py:22 121 | msgid "Firefox" 122 | msgstr "" 123 | 124 | #: templatetags/user_sessions.py:23 125 | msgid "Opera" 126 | msgstr "" 127 | 128 | #: templatetags/user_sessions.py:24 129 | msgid "Internet Explorer" 130 | msgstr "" 131 | 132 | #: templatetags/user_sessions.py:27 133 | msgid "Android" 134 | msgstr "" 135 | 136 | #: templatetags/user_sessions.py:28 137 | msgid "Linux" 138 | msgstr "" 139 | 140 | #: templatetags/user_sessions.py:29 141 | msgid "iPhone" 142 | msgstr "" 143 | 144 | #: templatetags/user_sessions.py:30 145 | msgid "iPad" 146 | msgstr "" 147 | 148 | #: templatetags/user_sessions.py:31 149 | msgid "OS X Mavericks" 150 | msgstr "" 151 | 152 | #: templatetags/user_sessions.py:32 153 | msgid "OS X Yosemite" 154 | msgstr "" 155 | 156 | #: templatetags/user_sessions.py:33 157 | msgid "OS X El Capitan" 158 | msgstr "" 159 | 160 | #: templatetags/user_sessions.py:34 161 | msgid "macOS Sierra" 162 | msgstr "" 163 | 164 | #: templatetags/user_sessions.py:35 165 | msgid "OS X" 166 | msgstr "" 167 | 168 | #: templatetags/user_sessions.py:36 169 | msgid "Windows XP" 170 | msgstr "" 171 | 172 | #: templatetags/user_sessions.py:37 173 | msgid "Windows Vista" 174 | msgstr "" 175 | 176 | #: templatetags/user_sessions.py:38 177 | msgid "Windows 7" 178 | msgstr "" 179 | 180 | #: templatetags/user_sessions.py:39 181 | msgid "Windows 8" 182 | msgstr "" 183 | 184 | #: templatetags/user_sessions.py:40 185 | msgid "Windows 8.1" 186 | msgstr "" 187 | 188 | #: templatetags/user_sessions.py:41 189 | msgid "Windows 10" 190 | msgstr "" 191 | 192 | #: templatetags/user_sessions.py:42 193 | msgid "Windows" 194 | msgstr "" 195 | 196 | #: templatetags/user_sessions.py:74 197 | #, python-format 198 | msgid "%(browser)s on %(device)s" 199 | msgstr "%(browser)s на %(device)s" 200 | -------------------------------------------------------------------------------- /user_sessions/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Daniele Faraglia , 2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-user-sessions\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 13 | "Last-Translator: Bouke Haarsma \n" 14 | "Language-Team: Italian (http://www.transifex.com/Bouke/django-user-sessions/language/it/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: it\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: admin.py:20 22 | msgid "Is Valid" 23 | msgstr "Valido" 24 | 25 | #: admin.py:25 26 | msgid "Active" 27 | msgstr "Attivo" 28 | 29 | #: admin.py:26 30 | msgid "Expired" 31 | msgstr "Scaduto" 32 | 33 | #: admin.py:37 34 | msgid "Owner" 35 | msgstr "Proprietario" 36 | 37 | #: admin.py:42 38 | msgid "Self" 39 | msgstr "Proprio" 40 | 41 | #: models.py:37 42 | msgid "session key" 43 | msgstr "chiave di sessione" 44 | 45 | #: models.py:39 46 | msgid "session data" 47 | msgstr "dati di sessione" 48 | 49 | #: models.py:40 50 | msgid "expiry date" 51 | msgstr "data di scadenza" 52 | 53 | #: models.py:44 54 | msgid "session" 55 | msgstr "sessione" 56 | 57 | #: models.py:45 58 | msgid "sessions" 59 | msgstr "sessioni" 60 | 61 | #: templates/user_sessions/session_list.html:5 62 | msgid "unknown on unknown" 63 | msgstr "" 64 | 65 | #: templates/user_sessions/session_list.html:6 66 | msgid "unknown" 67 | msgstr "" 68 | 69 | #: templates/user_sessions/session_list.html:8 70 | msgid "Active Sessions" 71 | msgstr "Sessioni attive" 72 | 73 | #: templates/user_sessions/session_list.html:13 74 | msgid "Location" 75 | msgstr "Luogo" 76 | 77 | #: templates/user_sessions/session_list.html:14 78 | msgid "Device" 79 | msgstr "Dispositivo" 80 | 81 | #: templates/user_sessions/session_list.html:15 82 | msgid "Last Activity" 83 | msgstr "Ultima attività" 84 | 85 | #: templates/user_sessions/session_list.html:16 86 | #: templates/user_sessions/session_list.html:34 87 | #: templates/user_sessions/session_list.html:36 88 | msgid "End Session" 89 | msgstr "Termina Sessione" 90 | 91 | #: templates/user_sessions/session_list.html:25 92 | #, python-format 93 | msgid "%(time)s ago (this session)" 94 | msgstr "%(time)s fa (in questa sessione)" 95 | 96 | #: templates/user_sessions/session_list.html:27 97 | #, python-format 98 | msgid "%(time)s ago" 99 | msgstr "%(time)s fa" 100 | 101 | #: templates/user_sessions/session_list.html:47 102 | msgid "" 103 | "You can also end all other sessions but the current.\n" 104 | " This will log you out on all other devices." 105 | msgstr "" 106 | 107 | #: templates/user_sessions/session_list.html:49 108 | msgid "End All Other Sessions" 109 | msgstr "" 110 | 111 | #: templatetags/user_sessions.py:20 112 | msgid "Chrome" 113 | msgstr "Chrome" 114 | 115 | #: templatetags/user_sessions.py:21 116 | msgid "Safari" 117 | msgstr "Safari" 118 | 119 | #: templatetags/user_sessions.py:22 120 | msgid "Firefox" 121 | msgstr "Firefox" 122 | 123 | #: templatetags/user_sessions.py:23 124 | msgid "Opera" 125 | msgstr "Opera" 126 | 127 | #: templatetags/user_sessions.py:24 128 | msgid "Internet Explorer" 129 | msgstr "Internet Explorer" 130 | 131 | #: templatetags/user_sessions.py:27 132 | msgid "Android" 133 | msgstr "Android" 134 | 135 | #: templatetags/user_sessions.py:28 136 | msgid "Linux" 137 | msgstr "Linux" 138 | 139 | #: templatetags/user_sessions.py:29 140 | msgid "iPhone" 141 | msgstr "iPhon" 142 | 143 | #: templatetags/user_sessions.py:30 144 | msgid "iPad" 145 | msgstr "iPad" 146 | 147 | #: templatetags/user_sessions.py:31 148 | msgid "OS X Mavericks" 149 | msgstr "" 150 | 151 | #: templatetags/user_sessions.py:32 152 | msgid "OS X Yosemite" 153 | msgstr "" 154 | 155 | #: templatetags/user_sessions.py:33 156 | msgid "OS X El Capitan" 157 | msgstr "" 158 | 159 | #: templatetags/user_sessions.py:34 160 | msgid "macOS Sierra" 161 | msgstr "" 162 | 163 | #: templatetags/user_sessions.py:35 164 | msgid "OS X" 165 | msgstr "OS X" 166 | 167 | #: templatetags/user_sessions.py:36 168 | msgid "Windows XP" 169 | msgstr "Windows XP" 170 | 171 | #: templatetags/user_sessions.py:37 172 | msgid "Windows Vista" 173 | msgstr "Windows Vista" 174 | 175 | #: templatetags/user_sessions.py:38 176 | msgid "Windows 7" 177 | msgstr "Windows 7" 178 | 179 | #: templatetags/user_sessions.py:39 180 | msgid "Windows 8" 181 | msgstr "Windows 8" 182 | 183 | #: templatetags/user_sessions.py:40 184 | msgid "Windows 8.1" 185 | msgstr "Windows 8.1" 186 | 187 | #: templatetags/user_sessions.py:41 188 | msgid "Windows 10" 189 | msgstr "" 190 | 191 | #: templatetags/user_sessions.py:42 192 | msgid "Windows" 193 | msgstr "Windows" 194 | 195 | #: templatetags/user_sessions.py:74 196 | #, python-format 197 | msgid "%(browser)s on %(device)s" 198 | msgstr "%(browser)s su %(device)s" 199 | -------------------------------------------------------------------------------- /user_sessions/locale/ar/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Bashar Al-Abdulhadi, 2013-2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-user-sessions\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 13 | "Last-Translator: Bouke Haarsma \n" 14 | "Language-Team: Arabic (http://www.transifex.com/Bouke/django-user-sessions/language/ar/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: ar\n" 19 | "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" 20 | 21 | #: admin.py:20 22 | msgid "Is Valid" 23 | msgstr "صالح" 24 | 25 | #: admin.py:25 26 | msgid "Active" 27 | msgstr "فعال" 28 | 29 | #: admin.py:26 30 | msgid "Expired" 31 | msgstr "منتهي" 32 | 33 | #: admin.py:37 34 | msgid "Owner" 35 | msgstr "المالك" 36 | 37 | #: admin.py:42 38 | msgid "Self" 39 | msgstr "شخصي" 40 | 41 | #: models.py:37 42 | msgid "session key" 43 | msgstr "مفتاح الجلسة" 44 | 45 | #: models.py:39 46 | msgid "session data" 47 | msgstr "بيانات الجلسة" 48 | 49 | #: models.py:40 50 | msgid "expiry date" 51 | msgstr "تاريخ الإنتهاء" 52 | 53 | #: models.py:44 54 | msgid "session" 55 | msgstr "الجلسة" 56 | 57 | #: models.py:45 58 | msgid "sessions" 59 | msgstr "الجلسات" 60 | 61 | #: templates/user_sessions/session_list.html:5 62 | msgid "unknown on unknown" 63 | msgstr "" 64 | 65 | #: templates/user_sessions/session_list.html:6 66 | msgid "unknown" 67 | msgstr "" 68 | 69 | #: templates/user_sessions/session_list.html:8 70 | msgid "Active Sessions" 71 | msgstr "الجلسات النشطة" 72 | 73 | #: templates/user_sessions/session_list.html:13 74 | msgid "Location" 75 | msgstr "المكان" 76 | 77 | #: templates/user_sessions/session_list.html:14 78 | msgid "Device" 79 | msgstr "الجهاز" 80 | 81 | #: templates/user_sessions/session_list.html:15 82 | msgid "Last Activity" 83 | msgstr "آخر نشاط" 84 | 85 | #: templates/user_sessions/session_list.html:16 86 | #: templates/user_sessions/session_list.html:34 87 | #: templates/user_sessions/session_list.html:36 88 | msgid "End Session" 89 | msgstr "إنهاء الجلسة" 90 | 91 | #: templates/user_sessions/session_list.html:25 92 | #, python-format 93 | msgid "%(time)s ago (this session)" 94 | msgstr "%(time)s مضت (هذه الجلسة)" 95 | 96 | #: templates/user_sessions/session_list.html:27 97 | #, python-format 98 | msgid "%(time)s ago" 99 | msgstr "%(time)s مضت" 100 | 101 | #: templates/user_sessions/session_list.html:47 102 | msgid "" 103 | "You can also end all other sessions but the current.\n" 104 | " This will log you out on all other devices." 105 | msgstr "" 106 | 107 | #: templates/user_sessions/session_list.html:49 108 | msgid "End All Other Sessions" 109 | msgstr "" 110 | 111 | #: templatetags/user_sessions.py:20 112 | msgid "Chrome" 113 | msgstr "جوجل كروم" 114 | 115 | #: templatetags/user_sessions.py:21 116 | msgid "Safari" 117 | msgstr "آبل سفاري" 118 | 119 | #: templatetags/user_sessions.py:22 120 | msgid "Firefox" 121 | msgstr "فايرفوكس" 122 | 123 | #: templatetags/user_sessions.py:23 124 | msgid "Opera" 125 | msgstr "أوبرا" 126 | 127 | #: templatetags/user_sessions.py:24 128 | msgid "Internet Explorer" 129 | msgstr "إنترنت إكسبلورر" 130 | 131 | #: templatetags/user_sessions.py:27 132 | msgid "Android" 133 | msgstr "أندرويد" 134 | 135 | #: templatetags/user_sessions.py:28 136 | msgid "Linux" 137 | msgstr "لينوكس" 138 | 139 | #: templatetags/user_sessions.py:29 140 | msgid "iPhone" 141 | msgstr "آيفون" 142 | 143 | #: templatetags/user_sessions.py:30 144 | msgid "iPad" 145 | msgstr "آيباد" 146 | 147 | #: templatetags/user_sessions.py:31 148 | msgid "OS X Mavericks" 149 | msgstr "" 150 | 151 | #: templatetags/user_sessions.py:32 152 | msgid "OS X Yosemite" 153 | msgstr "" 154 | 155 | #: templatetags/user_sessions.py:33 156 | msgid "OS X El Capitan" 157 | msgstr "" 158 | 159 | #: templatetags/user_sessions.py:34 160 | msgid "macOS Sierra" 161 | msgstr "" 162 | 163 | #: templatetags/user_sessions.py:35 164 | msgid "OS X" 165 | msgstr "ماك او اس اكس" 166 | 167 | #: templatetags/user_sessions.py:36 168 | msgid "Windows XP" 169 | msgstr "ويندوز أكس بي" 170 | 171 | #: templatetags/user_sessions.py:37 172 | msgid "Windows Vista" 173 | msgstr "ويندوز ڤيستا" 174 | 175 | #: templatetags/user_sessions.py:38 176 | msgid "Windows 7" 177 | msgstr "ويندوز 7" 178 | 179 | #: templatetags/user_sessions.py:39 180 | msgid "Windows 8" 181 | msgstr "ويندوز 8" 182 | 183 | #: templatetags/user_sessions.py:40 184 | msgid "Windows 8.1" 185 | msgstr "ويندوز 8.1" 186 | 187 | #: templatetags/user_sessions.py:41 188 | msgid "Windows 10" 189 | msgstr "" 190 | 191 | #: templatetags/user_sessions.py:42 192 | msgid "Windows" 193 | msgstr "ويندوز" 194 | 195 | #: templatetags/user_sessions.py:74 196 | #, python-format 197 | msgid "%(browser)s on %(device)s" 198 | msgstr "%(browser)s على %(device)s" 199 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Django User Sessions 3 | ==================== 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://github.com/jazzband/django-user-sessions/workflows/Test/badge.svg 10 | :alt: GitHub Actions 11 | :target: https://github.com/jazzband/django-user-sessions/actions 12 | 13 | .. image:: https://codecov.io/gh/jazzband/django-user-sessions/branch/master/graph/badge.svg 14 | :alt: Test Coverage 15 | :target: https://codecov.io/gh/jazzband/django-user-sessions 16 | 17 | .. image:: https://badge.fury.io/py/django-user-sessions.svg 18 | :alt: PyPI 19 | :target: https://pypi.org/project/django-user-sessions/ 20 | 21 | Django includes excellent built-in sessions, however all the data is hidden 22 | away into base64 encoded data. This makes it very difficult to run a query on 23 | all active sessions for a particular user. `django-user-sessions` fixes this 24 | and makes session objects a first class citizen like other ORM objects. It is 25 | a drop-in replacement for `django.contrib.sessions`. 26 | 27 | I would love to hear your feedback on this package. If you run into 28 | problems, please file an issue on GitHub, or contribute to the project by 29 | forking the repository and sending some pull requests. The package is 30 | translated into English, Dutch and other languages. Please contribute your own 31 | language using Transifex_. 32 | 33 | Also have a look at the bundled example templates and views to see how you 34 | can integrate the application into your project. 35 | 36 | Compatible with Django 3.2 and 4.2 on Python 3.8 to 3.11. 37 | Documentation is available at `readthedocs.org`_. 38 | 39 | 40 | Features 41 | ======== 42 | 43 | To get the list of a user's sessions: 44 | 45 | .. code-block:: python 46 | 47 | user.session_set.filter(expire_date__gt=now()) 48 | 49 | Or logout the user everywhere: 50 | 51 | .. code-block:: python 52 | 53 | user.session_set.all().delete() 54 | 55 | The user's IP address and user agent are also stored on the session. This 56 | allows to show a list of active sessions to the user in the admin: 57 | 58 | .. image:: https://i.imgur.com/YV9Nx3f.png 59 | 60 | And also in a custom layout: 61 | 62 | .. image:: https://i.imgur.com/d7kZtr9.png 63 | 64 | 65 | Installation 66 | ============ 67 | Refer to the `installation instructions`_ in the documentation. 68 | 69 | GeoIP 70 | ----- 71 | You need to setup GeoIP for the location detection to work. See the Django 72 | documentation on `installing GeoIP`_. 73 | 74 | 75 | Getting help 76 | ============ 77 | 78 | For general questions regarding this package, please hop over to Stack 79 | Overflow. If you think there is an issue with this package; check if the 80 | issue is already listed (either open or closed), and file an issue if 81 | it's not. 82 | 83 | 84 | Development 85 | =========== 86 | 87 | How to contribute 88 | ----------------- 89 | * Fork the repository on GitHub and start hacking. 90 | * Run the tests. 91 | * Send a pull request with your changes. 92 | * Provide a translation using Transifex_. 93 | 94 | Running tests 95 | ------------- 96 | This project aims for full code-coverage, this means that your code should be 97 | well-tested. Also test branches for hardened code. You can run the full test 98 | suite with:: 99 | 100 | make test 101 | 102 | Or run a specific test with:: 103 | 104 | make test TARGET=tests.tests.MiddlewareTest 105 | 106 | For Python compatibility, tox_ is used. You can run the full test suite with:: 107 | 108 | tox 109 | 110 | Releasing 111 | --------- 112 | The following actions are required to push a new version: 113 | 114 | * Update release notes 115 | * If any new translations strings were added, push the new source language to 116 | Transifex_. Make sure translators have sufficient time to translate those 117 | new strings:: 118 | 119 | make tx-push 120 | 121 | * Add migrations:: 122 | 123 | python example/manage.py makemigrations user_sessions 124 | git commit user_sessions/migrations -m "Added migrations" 125 | 126 | * Update translations:: 127 | 128 | make tx-pull 129 | 130 | * Package and upload:: 131 | 132 | bumpversion [major|minor|patch] 133 | git push && git push --tags 134 | python -m build --wheel 135 | twine upload dist/* 136 | 137 | 138 | License 139 | ======= 140 | This project is licensed under the MIT license. 141 | 142 | 143 | Credits 144 | ======= 145 | This library was written by `Bouke Haarsma`_ and contributors_. 146 | 147 | 148 | .. _Transifex: https://explore.transifex.com/Bouke/django-user-sessions/ 149 | .. _`readthedocs.org`: https://django-user-sessions.readthedocs.io/ 150 | .. _`installation instructions`: 151 | https://django-user-sessions.readthedocs.io/en/stable/installation.html 152 | .. _installing GeoIP: 153 | https://docs.djangoproject.com/en/2.0/ref/contrib/gis/geoip2/ 154 | .. _tox: https://tox.wiki/en/latest/ 155 | .. _Bouke Haarsma: 156 | https://github.com/Bouke 157 | .. _contributors: 158 | https://github.com/jazzband/django-user-sessions/graphs/contributors 159 | -------------------------------------------------------------------------------- /user_sessions/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Bouke Haarsma , 2017 7 | # elnappo , 2014 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: django-user-sessions\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 13 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 14 | "Last-Translator: Bouke Haarsma \n" 15 | "Language-Team: German (http://www.transifex.com/Bouke/django-user-sessions/language/de/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: de\n" 20 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 21 | 22 | #: admin.py:20 23 | msgid "Is Valid" 24 | msgstr "Gültig" 25 | 26 | #: admin.py:25 27 | msgid "Active" 28 | msgstr "Aktiv" 29 | 30 | #: admin.py:26 31 | msgid "Expired" 32 | msgstr "Abgelaufen" 33 | 34 | #: admin.py:37 35 | msgid "Owner" 36 | msgstr "Besitzer" 37 | 38 | #: admin.py:42 39 | msgid "Self" 40 | msgstr "Meine" 41 | 42 | #: models.py:37 43 | msgid "session key" 44 | msgstr "Sitzungsschlüssel" 45 | 46 | #: models.py:39 47 | msgid "session data" 48 | msgstr "Sitzungsdaten" 49 | 50 | #: models.py:40 51 | msgid "expiry date" 52 | msgstr "Ablaufdatum" 53 | 54 | #: models.py:44 55 | msgid "session" 56 | msgstr "Sitzung" 57 | 58 | #: models.py:45 59 | msgid "sessions" 60 | msgstr "Sitzungen" 61 | 62 | #: templates/user_sessions/session_list.html:5 63 | msgid "unknown on unknown" 64 | msgstr "unbekannt" 65 | 66 | #: templates/user_sessions/session_list.html:6 67 | msgid "unknown" 68 | msgstr "unbekannt" 69 | 70 | #: templates/user_sessions/session_list.html:8 71 | msgid "Active Sessions" 72 | msgstr "Aktive Sitzungen" 73 | 74 | #: templates/user_sessions/session_list.html:13 75 | msgid "Location" 76 | msgstr "Ort" 77 | 78 | #: templates/user_sessions/session_list.html:14 79 | msgid "Device" 80 | msgstr "Gerät" 81 | 82 | #: templates/user_sessions/session_list.html:15 83 | msgid "Last Activity" 84 | msgstr "Letzter Zugriff" 85 | 86 | #: templates/user_sessions/session_list.html:16 87 | #: templates/user_sessions/session_list.html:34 88 | #: templates/user_sessions/session_list.html:36 89 | msgid "End Session" 90 | msgstr "Sitzung beenden" 91 | 92 | #: templates/user_sessions/session_list.html:25 93 | #, python-format 94 | msgid "%(time)s ago (this session)" 95 | msgstr "vor %(time)s (diese Sitzung)" 96 | 97 | #: templates/user_sessions/session_list.html:27 98 | #, python-format 99 | msgid "%(time)s ago" 100 | msgstr "vor %(time)s" 101 | 102 | #: templates/user_sessions/session_list.html:47 103 | msgid "" 104 | "You can also end all other sessions but the current.\n" 105 | " This will log you out on all other devices." 106 | msgstr "" 107 | 108 | #: templates/user_sessions/session_list.html:49 109 | msgid "End All Other Sessions" 110 | msgstr "" 111 | 112 | #: templatetags/user_sessions.py:20 113 | msgid "Chrome" 114 | msgstr "Chrome" 115 | 116 | #: templatetags/user_sessions.py:21 117 | msgid "Safari" 118 | msgstr "Safari" 119 | 120 | #: templatetags/user_sessions.py:22 121 | msgid "Firefox" 122 | msgstr "Firefox" 123 | 124 | #: templatetags/user_sessions.py:23 125 | msgid "Opera" 126 | msgstr "Opera" 127 | 128 | #: templatetags/user_sessions.py:24 129 | msgid "Internet Explorer" 130 | msgstr "Internet Explorer" 131 | 132 | #: templatetags/user_sessions.py:27 133 | msgid "Android" 134 | msgstr "Android" 135 | 136 | #: templatetags/user_sessions.py:28 137 | msgid "Linux" 138 | msgstr "Linux" 139 | 140 | #: templatetags/user_sessions.py:29 141 | msgid "iPhone" 142 | msgstr "iPhone" 143 | 144 | #: templatetags/user_sessions.py:30 145 | msgid "iPad" 146 | msgstr "iPad" 147 | 148 | #: templatetags/user_sessions.py:31 149 | msgid "OS X Mavericks" 150 | msgstr "" 151 | 152 | #: templatetags/user_sessions.py:32 153 | msgid "OS X Yosemite" 154 | msgstr "" 155 | 156 | #: templatetags/user_sessions.py:33 157 | msgid "OS X El Capitan" 158 | msgstr "" 159 | 160 | #: templatetags/user_sessions.py:34 161 | msgid "macOS Sierra" 162 | msgstr "" 163 | 164 | #: templatetags/user_sessions.py:35 165 | msgid "OS X" 166 | msgstr "OS X" 167 | 168 | #: templatetags/user_sessions.py:36 169 | msgid "Windows XP" 170 | msgstr "Windows XP" 171 | 172 | #: templatetags/user_sessions.py:37 173 | msgid "Windows Vista" 174 | msgstr "Windows Vista" 175 | 176 | #: templatetags/user_sessions.py:38 177 | msgid "Windows 7" 178 | msgstr "Windows 7" 179 | 180 | #: templatetags/user_sessions.py:39 181 | msgid "Windows 8" 182 | msgstr "Windows 8" 183 | 184 | #: templatetags/user_sessions.py:40 185 | msgid "Windows 8.1" 186 | msgstr "Windows 8.1" 187 | 188 | #: templatetags/user_sessions.py:41 189 | msgid "Windows 10" 190 | msgstr "" 191 | 192 | #: templatetags/user_sessions.py:42 193 | msgid "Windows" 194 | msgstr "Windows" 195 | 196 | #: templatetags/user_sessions.py:74 197 | #, python-format 198 | msgid "%(browser)s on %(device)s" 199 | msgstr "%(browser)s auf %(device)s" 200 | -------------------------------------------------------------------------------- /user_sessions/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Bouke Haarsma , 2013-2014,2017 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-user-sessions\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-04-19 07:30+0200\n" 12 | "PO-Revision-Date: 2017-08-03 14:58+0000\n" 13 | "Last-Translator: Bouke Haarsma \n" 14 | "Language-Team: Dutch (http://www.transifex.com/Bouke/django-user-sessions/language/nl/)\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: nl\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: admin.py:20 22 | msgid "Is Valid" 23 | msgstr "is geldig" 24 | 25 | #: admin.py:25 26 | msgid "Active" 27 | msgstr "Actief" 28 | 29 | #: admin.py:26 30 | msgid "Expired" 31 | msgstr "Verlopen" 32 | 33 | #: admin.py:37 34 | msgid "Owner" 35 | msgstr "Eigenaar" 36 | 37 | #: admin.py:42 38 | msgid "Self" 39 | msgstr "Eigen" 40 | 41 | #: models.py:37 42 | msgid "session key" 43 | msgstr "sessiesleutel" 44 | 45 | #: models.py:39 46 | msgid "session data" 47 | msgstr "sessiegegevens" 48 | 49 | #: models.py:40 50 | msgid "expiry date" 51 | msgstr "verloopdatum" 52 | 53 | #: models.py:44 54 | msgid "session" 55 | msgstr "sessie" 56 | 57 | #: models.py:45 58 | msgid "sessions" 59 | msgstr "sessies" 60 | 61 | #: templates/user_sessions/session_list.html:5 62 | msgid "unknown on unknown" 63 | msgstr "onbekend" 64 | 65 | #: templates/user_sessions/session_list.html:6 66 | msgid "unknown" 67 | msgstr "onbekend" 68 | 69 | #: templates/user_sessions/session_list.html:8 70 | msgid "Active Sessions" 71 | msgstr "Actieve sessies" 72 | 73 | #: templates/user_sessions/session_list.html:13 74 | msgid "Location" 75 | msgstr "Lokatie" 76 | 77 | #: templates/user_sessions/session_list.html:14 78 | msgid "Device" 79 | msgstr "Apparaat" 80 | 81 | #: templates/user_sessions/session_list.html:15 82 | msgid "Last Activity" 83 | msgstr "Laatste activiteit" 84 | 85 | #: templates/user_sessions/session_list.html:16 86 | #: templates/user_sessions/session_list.html:34 87 | #: templates/user_sessions/session_list.html:36 88 | msgid "End Session" 89 | msgstr "Beëindig sessie" 90 | 91 | #: templates/user_sessions/session_list.html:25 92 | #, python-format 93 | msgid "%(time)s ago (this session)" 94 | msgstr "%(time)s geleden (deze sessie)" 95 | 96 | #: templates/user_sessions/session_list.html:27 97 | #, python-format 98 | msgid "%(time)s ago" 99 | msgstr "%(time)s geleden" 100 | 101 | #: templates/user_sessions/session_list.html:47 102 | msgid "" 103 | "You can also end all other sessions but the current.\n" 104 | " This will log you out on all other devices." 105 | msgstr "Je kunt al je sessies behalve de huidige beëindigen.\nHierdoor wordt je uitgelogd op alle andere apparaten." 106 | 107 | #: templates/user_sessions/session_list.html:49 108 | msgid "End All Other Sessions" 109 | msgstr "Beëindig alle andere sessies" 110 | 111 | #: templatetags/user_sessions.py:20 112 | msgid "Chrome" 113 | msgstr "Chrome" 114 | 115 | #: templatetags/user_sessions.py:21 116 | msgid "Safari" 117 | msgstr "Safari" 118 | 119 | #: templatetags/user_sessions.py:22 120 | msgid "Firefox" 121 | msgstr "Firefox" 122 | 123 | #: templatetags/user_sessions.py:23 124 | msgid "Opera" 125 | msgstr "Opera" 126 | 127 | #: templatetags/user_sessions.py:24 128 | msgid "Internet Explorer" 129 | msgstr "Internet Explorer" 130 | 131 | #: templatetags/user_sessions.py:27 132 | msgid "Android" 133 | msgstr "Android" 134 | 135 | #: templatetags/user_sessions.py:28 136 | msgid "Linux" 137 | msgstr "Linux" 138 | 139 | #: templatetags/user_sessions.py:29 140 | msgid "iPhone" 141 | msgstr "iPhone" 142 | 143 | #: templatetags/user_sessions.py:30 144 | msgid "iPad" 145 | msgstr "iPad" 146 | 147 | #: templatetags/user_sessions.py:31 148 | msgid "OS X Mavericks" 149 | msgstr "OS X Mavericks" 150 | 151 | #: templatetags/user_sessions.py:32 152 | msgid "OS X Yosemite" 153 | msgstr "OS X Yosemite" 154 | 155 | #: templatetags/user_sessions.py:33 156 | msgid "OS X El Capitan" 157 | msgstr "OS X El Capitan" 158 | 159 | #: templatetags/user_sessions.py:34 160 | msgid "macOS Sierra" 161 | msgstr "macOS Sierra" 162 | 163 | #: templatetags/user_sessions.py:35 164 | msgid "OS X" 165 | msgstr "OS X" 166 | 167 | #: templatetags/user_sessions.py:36 168 | msgid "Windows XP" 169 | msgstr "Windows XP" 170 | 171 | #: templatetags/user_sessions.py:37 172 | msgid "Windows Vista" 173 | msgstr "Windows Vista" 174 | 175 | #: templatetags/user_sessions.py:38 176 | msgid "Windows 7" 177 | msgstr "Windows 7" 178 | 179 | #: templatetags/user_sessions.py:39 180 | msgid "Windows 8" 181 | msgstr "Windows 8" 182 | 183 | #: templatetags/user_sessions.py:40 184 | msgid "Windows 8.1" 185 | msgstr "Windows 8.1" 186 | 187 | #: templatetags/user_sessions.py:41 188 | msgid "Windows 10" 189 | msgstr "Windows 10" 190 | 191 | #: templatetags/user_sessions.py:42 192 | msgid "Windows" 193 | msgstr "Windows" 194 | 195 | #: templatetags/user_sessions.py:74 196 | #, python-format 197 | msgid "%(browser)s on %(device)s" 198 | msgstr "%(browser)s op %(device)s" 199 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoUserSessions.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoUserSessions.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoUserSessions" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoUserSessions" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Django User Sessions documentation build configuration file, created by 3 | # sphinx-quickstart on Wed Jan 8 00:00:33 2014. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | 16 | from pkg_resources import get_distribution 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 19 | 20 | import django # noqa: E402 21 | 22 | django.setup() 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | #sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | #needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix of source filenames. 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | #source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = 'Django User Sessions' 55 | copyright = '2014, Bouke Haarsma' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | 62 | # The full version, including alpha/beta/rc tags. 63 | release = get_distribution('django-user-sessions').version 64 | # for example take major/minor 65 | version = '.'.join(release.split('.')[:2]) 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | #language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = ['_build'] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | #keep_warnings = False 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'default' 111 | 112 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org 113 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 114 | 115 | if not on_rtd: # only import and set the theme if we're building docs locally 116 | import sphinx_rtd_theme 117 | html_theme = 'sphinx_rtd_theme' 118 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 119 | 120 | # otherwise, readthedocs.org uses their theme by default, so no need to specify it 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | #html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | #html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | #html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | #html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | #html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | #html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = ['_static'] 150 | 151 | # Add any extra paths that contain custom files (such as robots.txt or 152 | # .htaccess) here, relative to this directory. These files are copied 153 | # directly to the root of the documentation. 154 | #html_extra_path = [] 155 | 156 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 157 | # using the given strftime format. 158 | #html_last_updated_fmt = '%b %d, %Y' 159 | 160 | # If true, SmartyPants will be used to convert quotes and dashes to 161 | # typographically correct entities. 162 | #html_use_smartypants = True 163 | 164 | # Custom sidebar templates, maps document names to template names. 165 | #html_sidebars = {} 166 | 167 | # Additional templates that should be rendered to pages, maps page names to 168 | # template names. 169 | #html_additional_pages = {} 170 | 171 | # If false, no module index is generated. 172 | #html_domain_indices = True 173 | 174 | # If false, no index is generated. 175 | #html_use_index = True 176 | 177 | # If true, the index is split into individual pages for each letter. 178 | #html_split_index = False 179 | 180 | # If true, links to the reST sources are added to the pages. 181 | #html_show_sourcelink = True 182 | 183 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 184 | #html_show_sphinx = True 185 | 186 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 187 | #html_show_copyright = True 188 | 189 | # If true, an OpenSearch description file will be output, and all pages will 190 | # contain a tag referring to it. The value of this option must be the 191 | # base URL from which the finished HTML is served. 192 | #html_use_opensearch = '' 193 | 194 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 195 | #html_file_suffix = None 196 | 197 | # Output file base name for HTML help builder. 198 | htmlhelp_basename = 'DjangoUserSessionsdoc' 199 | 200 | # -- Options for manual page output --------------------------------------- 201 | 202 | # One entry per manual page. List of tuples 203 | # (source start file, name, description, authors, manual section). 204 | man_pages = [ 205 | ('index', 'djangousersessions', 'Django User Sessions Documentation', 206 | ['Bouke Haarsma'], 1) 207 | ] 208 | 209 | # If true, show URL addresses after external links. 210 | #man_show_urls = False 211 | -------------------------------------------------------------------------------- /tests/test_template_filters.py: -------------------------------------------------------------------------------- 1 | from unittest import skipUnless 2 | 3 | from django.test import TestCase 4 | from django.test.utils import override_settings 5 | 6 | from user_sessions.templatetags.user_sessions import ( 7 | browser, city, country, device, location, platform, 8 | ) 9 | 10 | 11 | try: 12 | from django.contrib.gis.geoip2 import GeoIP2 13 | geoip = GeoIP2() 14 | geoip_msg = None 15 | except Exception as error_geoip2: # pragma: no cover 16 | try: 17 | from django.contrib.gis.geoip import GeoIP 18 | geoip = GeoIP() 19 | geoip_msg = None 20 | except Exception as error_geoip: 21 | geoip = None 22 | geoip_msg = str(error_geoip2) + " and " + str(error_geoip) 23 | 24 | 25 | class LocationTemplateFilterTest(TestCase): 26 | @override_settings(GEOIP_PATH=None) 27 | def test_no_location(self): 28 | with self.assertWarnsRegex( 29 | UserWarning, 30 | r"The address 127\.0\.0\.1 is not in the database", 31 | ): 32 | loc = location('127.0.0.1') 33 | self.assertEqual(loc, None) 34 | 35 | @skipUnless(geoip, geoip_msg) 36 | def test_city(self): 37 | self.assertEqual('San Diego', city('44.55.66.77')) 38 | 39 | @skipUnless(geoip, geoip_msg) 40 | def test_country(self): 41 | self.assertEqual('United States', country('8.8.8.8')) 42 | 43 | @skipUnless(geoip, geoip_msg) 44 | def test_locations(self): 45 | self.assertEqual('United States', location('8.8.8.8')) 46 | self.assertEqual('San Diego, United States', location('44.55.66.77')) 47 | 48 | 49 | class PlatformTemplateFilterTest(TestCase): 50 | def test_windows(self): 51 | # Generic Windows 52 | self.assertEqual("Windows", platform("Windows NT 5.1 not a real browser/10.3")) 53 | self.assertEqual("Windows", platform("Windows NT 6.0 not a real browser/10.3")) 54 | self.assertEqual("Windows", platform("Windows NT 6.1 not a real browser/10.3")) 55 | self.assertEqual("Windows", platform("Windows NT 6.2 not a real browser/10.3")) 56 | self.assertEqual("Windows", platform("Windows NT 6.3 not a real browser/10.3")) 57 | self.assertEqual("Windows", platform("Windows not a real browser/10.3")) 58 | 59 | # IE 60 | self.assertEqual( 61 | 'Windows', 62 | platform('Mozilla/4.0 (Windows; MSIE 6.0; Windows NT 5.1; SV1; ' 63 | '.NET CLR 2.0.50727)') 64 | ) 65 | self.assertEqual( 66 | 'Windows', 67 | platform('Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; ' 68 | 'Trident/4.0; SLCC1; .NET CLR 2.0.50727; .NET CLR 1.1.4322;' 69 | ' InfoPath.2; .NET CLR 3.5.21022; .NET CLR 3.5.30729; ' 70 | 'MS-RTC LM 8; OfficeLiveConnector.1.4; OfficeLivePatch.1.3;' 71 | ' .NET CLR 3.0.30729)') 72 | ) 73 | self.assertEqual( 74 | 'Windows', 75 | platform('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; ' 76 | 'Trident/6.0)') 77 | ) 78 | self.assertEqual( 79 | 'Windows', 80 | platform('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; ' 81 | 'Win64; x64; Trident/6.0)') 82 | ) 83 | self.assertEqual( 84 | 'Windows', 85 | platform('Mozilla/5.0 (IE 11.0; Windows NT 6.3; Trident/7.0; ' 86 | '.NET4.0E; .NET4.0C; rv:11.0) like Gecko') 87 | ) 88 | 89 | # Edge 90 | self.assertEqual( 91 | 'Windows', 92 | platform('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, ' 93 | 'like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136') 94 | ) 95 | self.assertEqual( 96 | 'Windows Mobile', 97 | platform('Mozilla/5.0 (Windows Mobile 10; Android 8.0.0; Microsoft; Lumia ' 98 | '950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.62 ' 99 | 'Mobile Safari/537.36 Edge/40.15254.369') 100 | ) 101 | 102 | # Edge Chromium 103 | self.assertEqual( 104 | 'Windows', 105 | platform('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 106 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.113 ' 107 | 'Safari/537.36 Edg/81.0.416.62') 108 | ) 109 | 110 | # Firefox 111 | self.assertEqual( 112 | 'Windows', 113 | platform('Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:22.0) ' 114 | 'Gecko/20130328 Firefox/22.0') 115 | ) 116 | 117 | # Chrome 118 | self.assertEqual( 119 | 'Windows', 120 | platform('Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (' 121 | 'KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36') 122 | ) 123 | 124 | def test_apple(self): 125 | # Generic iPad 126 | self.assertEqual("iPad", platform("iPad not a real browser/10.3")) 127 | 128 | # Generic iPhone 129 | self.assertEqual("iPhone", platform("iPhone not a real browser/10.3")) 130 | 131 | self.assertEqual( 132 | 'iPad', 133 | platform('Mozilla/5.0 (iPad; U; CPU OS 4_2_1 like Mac OS X; ja-jp) ' 134 | 'AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 ' 135 | 'Mobile/8C148 Safari/6533.18.5') 136 | ) 137 | 138 | self.assertEqual( 139 | 'iPhone', 140 | platform('Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) ' 141 | 'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' 142 | 'Mobile/11A465 Safari/9537.53') 143 | ) 144 | 145 | self.assertEqual( 146 | 'macOS', 147 | platform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) ' 148 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' 149 | 'Chrome/85.0.4178.0 Safari/537.36') 150 | ) 151 | self.assertEqual( 152 | 'macOS', 153 | platform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) ' 154 | 'Gecko/20100101 Firefox/77.0') 155 | ) 156 | self.assertEqual( 157 | 'macOS', 158 | platform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) ' 159 | 'AppleWebKit/536.26.17 (KHTML, like Gecko) Version/6.0.2 ' 160 | 'Safari/536.26.17') 161 | ) 162 | 163 | # Edge Chromium 164 | self.assertEqual( 165 | 'macOS', 166 | platform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) ' 167 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 ' 168 | 'Safari/537.36 Edg/85.0.564.51') 169 | ) 170 | 171 | def test_android(self): 172 | # androids identify themselves as Safari to get the good stuff 173 | self.assertEqual( 174 | 'Android', 175 | platform('Mozilla/5.0 (Linux; U; Android 1.5; de-de; HTC Magic ' 176 | 'Build/CRB17) AppleWebKit/528.5+ (KHTML, like Gecko) ' 177 | 'Version/3.1.2 Mobile Safari/525.20.1') 178 | ) 179 | 180 | # Edge Chromium 181 | self.assertEqual( 182 | 'Android', 183 | platform('Mozilla/5.0 (Linux; Android 11; Pixel 3 XL) ' 184 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.116 ' 185 | 'Mobile Safari/537.36 EdgA/45.07.4.5059') 186 | ) 187 | 188 | def test_linux_only(self): 189 | self.assertEqual("Linux", platform("Linux not a real browser/10.3")) 190 | 191 | 192 | class BrowserTemplateFilterTest(TestCase): 193 | def test_ie(self): 194 | self.assertEqual( 195 | 'Internet Explorer', 196 | browser('Mozilla/4.0 (Windows; MSIE 6.0; Windows NT 5.1; SV1; ' 197 | '.NET CLR 2.0.50727)') 198 | ) 199 | self.assertEqual( 200 | 'Internet Explorer', 201 | browser('Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; ' 202 | 'Trident/4.0; SLCC1; .NET CLR 2.0.50727; .NET CLR 1.1.4322;' 203 | ' InfoPath.2; .NET CLR 3.5.21022; .NET CLR 3.5.30729; ' 204 | 'MS-RTC LM 8; OfficeLiveConnector.1.4; OfficeLivePatch.1.3;' 205 | ' .NET CLR 3.0.30729)') 206 | ) 207 | self.assertEqual( 208 | 'Internet Explorer', 209 | browser('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; ' 210 | 'Trident/6.0)') 211 | ) 212 | self.assertEqual( 213 | 'Internet Explorer', 214 | browser('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; ' 215 | 'Win64; x64; Trident/6.0)') 216 | ) 217 | self.assertEqual( 218 | 'Internet Explorer', 219 | browser('Mozilla/5.0 (IE 11.0; Windows NT 6.3; Trident/7.0; ' 220 | '.NET4.0E; .NET4.0C; rv:11.0) like Gecko') 221 | ) 222 | 223 | def test_edge(self): 224 | self.assertEqual( 225 | 'Edge', 226 | browser('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, ' 227 | 'like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136') 228 | ) 229 | self.assertEqual( 230 | 'Edge', 231 | browser('Mozilla/5.0 (Windows Mobile 10; Android 8.0.0; Microsoft; Lumia ' 232 | '950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.62 ' 233 | 'Mobile Safari/537.36 Edge/40.15254.369') 234 | ) 235 | 236 | def test_edge_chromium(self): 237 | self.assertEqual( 238 | 'Edge', 239 | browser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 240 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.113 ' 241 | 'Safari/537.36 Edg/81.0.416.62') 242 | ) 243 | self.assertEqual( 244 | 'Edge', 245 | browser('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) ' 246 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 ' 247 | 'Safari/537.36 Edg/85.0.564.51') 248 | ) 249 | self.assertEqual( 250 | 'Edge', 251 | browser('Mozilla/5.0 (Linux; Android 11; Pixel 3 XL) ' 252 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.116 ' 253 | 'Mobile Safari/537.36 EdgA/45.07.4.5059') 254 | ) 255 | 256 | def test_safari(self): 257 | self.assertEqual( 258 | 'Safari', 259 | browser('Mozilla/5.0 (iPad; U; CPU OS 4_2_1 like Mac OS X; ja-jp) ' 260 | 'AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 ' 261 | 'Mobile/8C148 Safari/6533.18.5') 262 | ) 263 | self.assertEqual( 264 | 'Safari', 265 | browser('Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) ' 266 | 'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' 267 | 'Mobile/11A465 Safari/9537.53') 268 | ) 269 | self.assertEqual( 270 | 'Safari', 271 | browser('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) ' 272 | 'AppleWebKit/536.26.17 (KHTML, like Gecko) Version/6.0.2 ' 273 | 'Safari/536.26.17') 274 | ) 275 | 276 | self.assertEqual("Safari", browser("Not a legit OS Safari/5.2")) 277 | 278 | def test_chrome(self): 279 | self.assertEqual( 280 | 'Chrome', 281 | browser('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) ' 282 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' 283 | 'Chrome/85.0.4178.0 Safari/537.36') 284 | ) 285 | 286 | self.assertEqual( 287 | 'Chrome', 288 | browser('Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (' 289 | 'KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36') 290 | ) 291 | 292 | self.assertEqual("Chrome", browser("Not a legit OS Chrome/54.0.32")) 293 | 294 | def test_firefox(self): 295 | self.assertEqual( 296 | 'Firefox', 297 | browser('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) ' 298 | 'Gecko/20100101 Firefox/77.0') 299 | ) 300 | 301 | self.assertEqual( 302 | 'Firefox', 303 | browser('Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:22.0) ' 304 | 'Gecko/20130328 Firefox/22.0') 305 | ) 306 | 307 | self.assertEqual("Firefox", browser("Not a legit OS Firefox/51.0")) 308 | 309 | def test_android(self): 310 | # androids identify themselves as Safari to get the good stuff 311 | self.assertEqual( 312 | 'Safari', 313 | browser('Mozilla/5.0 (Linux; U; Android 1.5; de-de; HTC Magic ' 314 | 'Build/CRB17) AppleWebKit/528.5+ (KHTML, like Gecko) ' 315 | 'Version/3.1.2 Mobile Safari/525.20.1') 316 | ) 317 | 318 | 319 | class DeviceTemplateFilterTest(TestCase): 320 | def test_ie(self): 321 | self.assertEqual( 322 | 'Internet Explorer on Windows', 323 | device('Mozilla/4.0 (Windows; MSIE 6.0; Windows NT 5.1; SV1; ' 324 | '.NET CLR 2.0.50727)') 325 | ) 326 | self.assertEqual( 327 | 'Internet Explorer on Windows', 328 | device('Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; ' 329 | 'Trident/4.0; SLCC1; .NET CLR 2.0.50727; .NET CLR 1.1.4322;' 330 | ' InfoPath.2; .NET CLR 3.5.21022; .NET CLR 3.5.30729; ' 331 | 'MS-RTC LM 8; OfficeLiveConnector.1.4; OfficeLivePatch.1.3;' 332 | ' .NET CLR 3.0.30729)') 333 | ) 334 | self.assertEqual( 335 | 'Internet Explorer on Windows', 336 | device('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; ' 337 | 'Trident/6.0)') 338 | ) 339 | self.assertEqual( 340 | 'Internet Explorer on Windows', 341 | device('Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; ' 342 | 'Win64; x64; Trident/6.0)') 343 | ) 344 | self.assertEqual( 345 | 'Internet Explorer on Windows', 346 | device('Mozilla/5.0 (IE 11.0; Windows NT 6.3; Trident/7.0; ' 347 | '.NET4.0E; .NET4.0C; rv:11.0) like Gecko') 348 | ) 349 | 350 | def test_apple(self): 351 | self.assertEqual( 352 | 'Safari on iPad', 353 | device('Mozilla/5.0 (iPad; U; CPU OS 4_2_1 like Mac OS X; ja-jp) ' 354 | 'AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 ' 355 | 'Mobile/8C148 Safari/6533.18.5') 356 | ) 357 | self.assertEqual( 358 | 'Safari on iPhone', 359 | device('Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) ' 360 | 'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' 361 | 'Mobile/11A465 Safari/9537.53') 362 | ) 363 | self.assertEqual( 364 | 'Chrome on macOS', 365 | device('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) ' 366 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' 367 | 'Chrome/85.0.4178.0 Safari/537.36') 368 | ) 369 | self.assertEqual( 370 | 'Firefox on macOS', 371 | device('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) ' 372 | 'Gecko/20100101 Firefox/77.0') 373 | ) 374 | self.assertEqual( 375 | 'Safari on macOS', 376 | device('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) ' 377 | 'AppleWebKit/536.26.17 (KHTML, like Gecko) Version/6.0.2 ' 378 | 'Safari/536.26.17') 379 | ) 380 | 381 | def test_android(self): 382 | # androids identify themselves as Safari to get the good stuff 383 | self.assertEqual( 384 | 'Safari on Android', 385 | device('Mozilla/5.0 (Linux; U; Android 1.5; de-de; HTC Magic ' 386 | 'Build/CRB17) AppleWebKit/528.5+ (KHTML, like Gecko) ' 387 | 'Version/3.1.2 Mobile Safari/525.20.1') 388 | ) 389 | 390 | def test_firefox(self): 391 | self.assertEqual( 392 | 'Firefox on Windows', 393 | device('Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:22.0) ' 394 | 'Gecko/20130328 Firefox/22.0') 395 | ) 396 | 397 | def test_chrome(self): 398 | self.assertEqual( 399 | 'Chrome on Windows', 400 | device('Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (' 401 | 'KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36') 402 | ) 403 | 404 | def test_edge(self): 405 | self.assertEqual( 406 | 'Edge on Windows', 407 | device('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, ' 408 | 'like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136') 409 | ) 410 | self.assertEqual( 411 | 'Edge on Windows Mobile', 412 | device('Mozilla/5.0 (Windows Mobile 10; Android 8.0.0; Microsoft; Lumia ' 413 | '950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.62 ' 414 | 'Mobile Safari/537.36 Edge/40.15254.369') 415 | ) 416 | 417 | def test_edge_chromium(self): 418 | self.assertEqual( 419 | 'Edge on Windows', 420 | device('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 421 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.113 ' 422 | 'Safari/537.36 Edg/81.0.416.62') 423 | ) 424 | self.assertEqual( 425 | 'Edge on macOS', 426 | device('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) ' 427 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 ' 428 | 'Safari/537.36 Edg/85.0.564.51') 429 | ) 430 | self.assertEqual( 431 | 'Edge on Android', 432 | device('Mozilla/5.0 (Linux; Android 11; Pixel 3 XL) ' 433 | 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.116 ' 434 | 'Mobile Safari/537.36 EdgA/45.07.4.5059') 435 | ) 436 | 437 | def test_firefox_only(self): 438 | self.assertEqual("Firefox", device("Not a legit OS Firefox/51.0")) 439 | 440 | def test_chrome_only(self): 441 | self.assertEqual("Chrome", device("Not a legit OS Chrome/54.0.32")) 442 | 443 | def test_safari_only(self): 444 | self.assertEqual("Safari", device("Not a legit OS Safari/5.2")) 445 | 446 | def test_linux_only(self): 447 | self.assertEqual("Linux", device("Linux not a real browser/10.3")) 448 | 449 | def test_ipad_only(self): 450 | self.assertEqual("iPad", device("iPad not a real browser/10.3")) 451 | 452 | def test_iphone_only(self): 453 | self.assertEqual("iPhone", device("iPhone not a real browser/10.3")) 454 | 455 | def test_windowsxp_only(self): 456 | self.assertEqual("Windows", device("Windows NT 5.1 not a real browser/10.3")) 457 | 458 | def test_windowsvista_only(self): 459 | self.assertEqual("Windows", device("Windows NT 6.0 not a real browser/10.3")) 460 | 461 | def test_windows7_only(self): 462 | self.assertEqual("Windows", device("Windows NT 6.1 not a real browser/10.3")) 463 | 464 | def test_windows8_only(self): 465 | self.assertEqual("Windows", device("Windows NT 6.2 not a real browser/10.3")) 466 | 467 | def test_windows81_only(self): 468 | self.assertEqual("Windows", device("Windows NT 6.3 not a real browser/10.3")) 469 | 470 | def test_windows_only(self): 471 | self.assertEqual("Windows", device("Windows not a real browser/10.3")) 472 | --------------------------------------------------------------------------------