├── tests ├── __init__.py ├── geoip │ ├── GeoIP2-City-Test.mmdb │ └── GeoIP2-Country-Test.mmdb ├── test_system_checks.py ├── conftest.py ├── urls.py ├── test_clearsessions.py ├── test_admin.py ├── settings.py ├── test_middleware.py ├── test_model.py └── test_sessionstore.py ├── qsessions ├── backends │ ├── __init__.py │ ├── cached_db.py │ └── db.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clearsessions.py │ │ └── download_geoip_db.py ├── migrations │ ├── __init__.py │ ├── 0002_session_created_at.py │ └── 0001_initial.py ├── __init__.py ├── apps.py ├── middleware.py ├── geoip.py ├── admin.py └── models.py ├── .coveragerc ├── .gitignore ├── MANIFEST.in ├── pyproject.toml ├── .editorconfig ├── LICENSE.txt ├── .pre-commit-config.yaml ├── setup.py ├── .github └── workflows │ └── test.yml ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/__init__.py: -------------------------------------------------------------------------------- 1 | IP_SESSION_KEY = "_qsessions_ip" 2 | USER_AGENT_SESSION_KEY = "_qsessions_user_agent" 3 | -------------------------------------------------------------------------------- /tests/geoip/GeoIP2-City-Test.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-qsessions/HEAD/tests/geoip/GeoIP2-City-Test.mmdb -------------------------------------------------------------------------------- /tests/geoip/GeoIP2-Country-Test.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-qsessions/HEAD/tests/geoip/GeoIP2-Country-Test.mmdb -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *venv* 4 | *site-packages* 5 | qsessions/management/commands/download_geoip_db.py 6 | setup.py 7 | -------------------------------------------------------------------------------- /tests/test_system_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | 3 | 4 | def test_system_checks(): 5 | call_command("check") 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*cache 4 | .coverage 5 | .python-version 6 | .idea/ 7 | .tox/ 8 | build/ 9 | dist/ 10 | htmlcov 11 | venv/ 12 | -------------------------------------------------------------------------------- /qsessions/management/commands/clearsessions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.management.commands.clearsessions import Command 2 | 3 | __all__ = ("Command",) 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | include CHANGELOG.md 4 | recursive-include qsessions/locale * 5 | include tox.ini 6 | recursive-include tests *.mmdb 7 | recursive-include tests *.py 8 | -------------------------------------------------------------------------------- /qsessions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DjangoQsessionsConfig(AppConfig): 6 | name = "qsessions" 7 | verbose_name = _("Sessions") 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | DJANGO_SETTINGS_MODULE = "tests.settings" 3 | norecursedirs = ".git" 4 | django_find_project = false 5 | pythonpath = ["."] 6 | 7 | [tool.black] 8 | line-length = 120 9 | include = '\.pyi?$' 10 | exclude = '/\..+/' 11 | 12 | [tool.isort] 13 | profile = "black" 14 | line_length = 120 15 | skip_gitignore = true 16 | -------------------------------------------------------------------------------- /qsessions/migrations/0002_session_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.2 on 2018-02-05 06:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("qsessions", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField(model_name="session", name="created_at", field=models.DateTimeField(null=True)), 13 | ] 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from qsessions.models import Session 5 | 6 | SESSION_ENGINES = [ 7 | "qsessions.backends.db", 8 | "qsessions.backends.cached_db", 9 | ] 10 | 11 | 12 | @pytest.fixture(autouse=True, name="SessionStore", params=SESSION_ENGINES) 13 | def session_store(request): 14 | settings.SESSION_ENGINE = request.param 15 | return Session.get_session_store_class() 16 | -------------------------------------------------------------------------------- /qsessions/backends/cached_db.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.backends.cached_db import SessionStore as DjangoCachedDBStore 2 | 3 | from .db import SessionStore as QSessionsDBStore 4 | 5 | KEY_PREFIX = "qsessions.q_cached_db" 6 | 7 | 8 | class SessionStore(QSessionsDBStore, DjangoCachedDBStore): 9 | """ 10 | Implements cached, database backed sessions, with a foreign key to User. 11 | It also stores IP and User Agent. 12 | """ 13 | 14 | cache_key_prefix = KEY_PREFIX 15 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpResponse, JsonResponse 3 | from django.urls import path 4 | 5 | 6 | def read_session(request): 7 | return JsonResponse(dict(request.session)) 8 | 9 | 10 | def modify_session(request): 11 | request.session["FOO"] = "BAR" 12 | return HttpResponse("") 13 | 14 | 15 | urlpatterns = [ 16 | path("read_session/", read_session), 17 | path("modify_session/", modify_session), 18 | path("admin/", admin.site.urls), 19 | ] 20 | -------------------------------------------------------------------------------- /qsessions/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.middleware import SessionMiddleware as DjSessionMiddleware 3 | 4 | 5 | class SessionMiddleware(DjSessionMiddleware): 6 | def process_request(self, request): 7 | session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 8 | request.session = self.SessionStore( 9 | ip=request.META.get("REMOTE_ADDR", None), 10 | user_agent=request.headers.get("User-Agent", ""), 11 | session_key=session_key, 12 | ) 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | max_line_length = 120 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | 14 | # Do not add "md" here! It breaks Markdown re-formatting in PyCharm. 15 | [*.{json,yml,yaml,html,md}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | # Shorter lines in documentation files improves readability 20 | max_line_length = 80 21 | # 2 spaces at the end of a line forces a line break in MarkDown 22 | trim_trailing_whitespace = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /tests/test_clearsessions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | 6 | from qsessions.models import Session 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_can_call(): 11 | Session.objects.create( 12 | session_key="s1", 13 | expire_date=datetime.now() + timedelta(hours=1), 14 | ip="127.0.0.1", 15 | ) 16 | Session.objects.create( 17 | session_key="s2", 18 | expire_date=datetime.now() - timedelta(hours=1), 19 | ip="127.0.0.1", 20 | ) 21 | assert Session.objects.count() == 2 22 | call_command("clearsessions") 23 | assert Session.objects.count() == 1 24 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | 5 | @pytest.mark.django_db 6 | @pytest.mark.filterwarnings("ignore:The address 127.0.0.1 is not in the database") 7 | def test_smoke_admin(admin_client): 8 | admin_client.get("/modify_session/", HTTP_USER_AGENT="Chrome/70.0.3538.102", REMOTE_ADDR="89.160.20.112") 9 | resp = admin_client.get("/admin/qsessions/session/?active=1&owner=my") 10 | assert resp.status_code == 200 11 | content = resp.content.decode("UTF-8") 12 | assert "Linköping, Sweden" in content # From REMOTE_ADDR 13 | assert "Chrome 70.0.3538" in content # From HTTP_USER_AGENT 14 | resp = admin_client.get( 15 | f"/admin/qsessions/session/{admin_client.cookies[settings.SESSION_COOKIE_NAME].value}/change/" 16 | ) 17 | assert "FOO" in resp.content.decode("UTF-8") # Set by modify_session 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2018 Mohammad Javad Naderi 2 | Copyright (C) 2014 Bouke Haarsma 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /qsessions/geoip.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.contrib.gis.geoip2 import HAS_GEOIP2 4 | 5 | 6 | def ip_to_location_info(ip): 7 | """ 8 | Get a dictionary of location info for a given IP address. 9 | 10 | The format of the dictionary is the same provided by the functions 11 | in django.contrib.gis.geoip2.base.GeoIP2. 12 | """ 13 | 14 | if not HAS_GEOIP2: 15 | return None 16 | 17 | from django.contrib.gis.geoip2 import GeoIP2 18 | 19 | try: 20 | g = GeoIP2() 21 | except Exception as e: 22 | warnings.warn(str(e)) 23 | return None 24 | 25 | try: 26 | return g.city(ip) 27 | except Exception: 28 | try: 29 | return g.country(ip) 30 | except Exception as e: 31 | warnings.warn(str(e)) 32 | 33 | 34 | def ip_to_location(ip): 35 | """ 36 | Transform an IP address into an approximate location. 37 | 38 | Example output: 39 | 40 | * Zwolle, The Netherlands 41 | * The Netherlands 42 | * None 43 | """ 44 | loc = ip_to_location_info(ip) 45 | if not loc: 46 | return None 47 | 48 | if loc.get("country_name"): 49 | if loc.get("city"): 50 | return f"{loc['city']}, {loc['country_name']}" 51 | return loc["country_name"] 52 | 53 | return None 54 | -------------------------------------------------------------------------------- /.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 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: "v4.6.0" 6 | hooks: 7 | - id: trailing-whitespace # trims trailing whitespace 8 | args: [--markdown-linebreak-ext=md] 9 | - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline 10 | - id: check-yaml # checks syntax of yaml files 11 | - id: check-json # checks syntax of json files 12 | - id: check-added-large-files # prevent giant files from being committed 13 | - id: fix-encoding-pragma # removes "# -*- coding: utf-8 -*-" from python files (since we only support python 3) 14 | args: [--remove] 15 | - id: check-merge-conflict # check for files that contain merge conflict strings 16 | 17 | - repo: https://github.com/adamchainz/django-upgrade 18 | rev: "1.18.0" 19 | hooks: 20 | - id: django-upgrade 21 | args: [--target-version, "4.2"] 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: "v3.16.0" 25 | hooks: 26 | - id: pyupgrade 27 | args: [--py38-plus] 28 | 29 | - repo: https://github.com/pycqa/isort 30 | rev: "5.13.2" 31 | hooks: 32 | - id: isort 33 | name: isort (python) 34 | 35 | - repo: https://github.com/psf/black 36 | rev: "24.4.2" 37 | hooks: 38 | - id: black 39 | -------------------------------------------------------------------------------- /qsessions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.2 on 2017-12-19 16:00 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import qsessions.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Session", 20 | fields=[ 21 | ( 22 | "session_key", 23 | models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name="session key"), 24 | ), 25 | ("session_data", models.TextField(verbose_name="session data")), 26 | ("expire_date", models.DateTimeField(db_index=True, verbose_name="expire date")), 27 | ("user_agent", models.CharField(blank=True, max_length=300, null=True)), 28 | ("updated_at", models.DateTimeField(auto_now=True)), 29 | ("ip", models.GenericIPAddressField(blank=True, null=True, verbose_name="IP")), 30 | ( 31 | "user", 32 | models.ForeignKey( 33 | null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 34 | ), 35 | ), 36 | ], 37 | options={"verbose_name": "session", "abstract": False, "verbose_name_plural": "sessions"}, 38 | managers=[("objects", qsessions.models.SessionManager())], 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | SECRET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 5 | 6 | DEBUG = True 7 | USE_TZ = False 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "qsessions", 14 | "django.contrib.messages", 15 | ] 16 | 17 | MIDDLEWARE = [ 18 | "qsessions.middleware.SessionMiddleware", 19 | "django.middleware.common.CommonMiddleware", 20 | "django.middleware.csrf.CsrfViewMiddleware", 21 | "django.contrib.auth.middleware.AuthenticationMiddleware", 22 | "django.contrib.messages.middleware.MessageMiddleware", 23 | ] 24 | 25 | ROOT_URLCONF = "tests.urls" 26 | 27 | TEMPLATES = [ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "DIRS": [], 31 | "APP_DIRS": True, 32 | "OPTIONS": { 33 | "context_processors": [ 34 | "django.template.context_processors.debug", 35 | "django.template.context_processors.request", 36 | "django.contrib.auth.context_processors.auth", 37 | "django.contrib.messages.context_processors.messages", 38 | ], 39 | }, 40 | }, 41 | ] 42 | 43 | DATABASES = { 44 | "default": { 45 | "ENGINE": "django.db.backends.sqlite3", 46 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 47 | } 48 | } 49 | 50 | STATIC_URL = "/static/" 51 | 52 | GEOIP_PATH = os.path.join(BASE_DIR, "geoip") 53 | GEOIP_CITY = "GeoIP2-City-Test.mmdb" 54 | GEOIP_COUNTRY = "GeoIP2-Country-Test.mmdb" 55 | 56 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} 57 | -------------------------------------------------------------------------------- /qsessions/backends/db.py: -------------------------------------------------------------------------------- 1 | from django.contrib import auth 2 | from django.contrib.sessions.backends.db import SessionStore as DjangoDBStore 3 | 4 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY 5 | 6 | 7 | class SessionStore(DjangoDBStore): 8 | """ 9 | Implements database backed sessions, with a foreign key to User. 10 | It also stores IP and User Agent. 11 | """ 12 | 13 | def __init__(self, session_key=None, user_agent=None, ip=None): 14 | self.user_agent = user_agent[:300] if user_agent else user_agent 15 | self.ip = ip 16 | super().__init__(session_key) 17 | 18 | @classmethod 19 | def get_model_class(cls): 20 | from qsessions.models import Session 21 | 22 | return Session 23 | 24 | def load(self): 25 | data = super().load() 26 | if data.get(USER_AGENT_SESSION_KEY) != self.user_agent or data.get(IP_SESSION_KEY) != self.ip: 27 | # If IP or User Agent has changed, set modified to True in order to save 28 | # the new IP and User Agent 29 | self.modified = True 30 | return data 31 | 32 | def save(self, must_create=False): 33 | # Store IP and User Agent in session_data 34 | if USER_AGENT_SESSION_KEY not in self or self[USER_AGENT_SESSION_KEY] != self.user_agent: 35 | self[USER_AGENT_SESSION_KEY] = self.user_agent 36 | if IP_SESSION_KEY not in self or self[IP_SESSION_KEY] != self.ip: 37 | self[IP_SESSION_KEY] = self.ip 38 | super().save(must_create) 39 | 40 | def create_model_instance(self, data): 41 | # Store User, User Agent, and IP in Session (in DB). 42 | return self.model( 43 | session_key=self._get_or_create_session_key(), 44 | session_data=self.encode(data), 45 | expire_date=self.get_expiry_date(), 46 | user_id=self.get(auth.SESSION_KEY), 47 | user_agent=self.user_agent, 48 | ip=self.ip, 49 | ) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="UTF-8") as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | dev_requirements = [ 12 | "pre-commit", 13 | "pytest>=7", 14 | "pytest-cov", 15 | "pytest-django", 16 | ] 17 | 18 | geoip_requirements = ["geoip2>=4.1.0"] 19 | 20 | setup( 21 | name="django-qsessions", 22 | version="2.1.0", 23 | description="Extended session backends for Django", 24 | long_description=README, 25 | long_description_content_type="text/markdown", 26 | author="Mohammad Javad Naderi", 27 | url="https://github.com/QueraTeam/django-qsessions", 28 | download_url="https://pypi.python.org/pypi/django-qsessions", 29 | license="MIT", 30 | packages=find_packages(".", include=("qsessions", "qsessions.*")), 31 | include_package_data=True, 32 | install_requires=["Django >= 4.2", "ua-parser[regex] >= 1.0.1"], 33 | extras_require={ 34 | "dev": dev_requirements + geoip_requirements, 35 | "geoip2": geoip_requirements, 36 | }, 37 | tests_require=dev_requirements, 38 | classifiers=[ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: Web Environment", 41 | "Framework :: Django", 42 | "Framework :: Django :: 4.2", 43 | "Framework :: Django :: 5.0", 44 | "Framework :: Django :: 5.1", 45 | "Framework :: Django :: 5.2", 46 | "Intended Audience :: Developers", 47 | "License :: OSI Approved :: MIT License", 48 | "Operating System :: OS Independent", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | "Topic :: Internet :: WWW/HTTP :: Session", 56 | "Topic :: Security", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.contrib.auth import SESSION_KEY 6 | from django.contrib.auth.models import User 7 | from django.urls import reverse 8 | 9 | from qsessions import USER_AGENT_SESSION_KEY 10 | from qsessions.models import Session 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_unmodified_session(client): 15 | client.get("/", HTTP_USER_AGENT="TestUA/1.1") 16 | assert settings.SESSION_COOKIE_NAME not in client.cookies 17 | 18 | 19 | @pytest.mark.django_db 20 | @pytest.mark.parametrize("logged_in", (False, True)) 21 | def test_modify_session(client, logged_in): 22 | if logged_in: 23 | user = User.objects.create_superuser("user", "", "secret") 24 | client.force_login(user) 25 | else: 26 | user = None 27 | 28 | client.get("/read_session/", HTTP_USER_AGENT="TestUA/1.1") 29 | client.get("/modify_session/", HTTP_USER_AGENT="TestUA/1.1") 30 | data = json.loads(client.get("/read_session/", HTTP_USER_AGENT="TestUA/1.1").content.decode("UTF-8")) 31 | assert data["FOO"] == "BAR" 32 | assert data[USER_AGENT_SESSION_KEY] == "TestUA/1.1" 33 | if user: 34 | assert str(data[SESSION_KEY]) == str(user.id) 35 | 36 | assert settings.SESSION_COOKIE_NAME in client.cookies 37 | session = Session.objects.get(pk=client.cookies[settings.SESSION_COOKIE_NAME].value) 38 | assert session.user_agent == "TestUA/1.1" 39 | assert session.ip == "127.0.0.1" 40 | assert session.user == user 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_login(client): 45 | admin_login_url = reverse("admin:login") 46 | user = User.objects.create_superuser("user", "", "secret") 47 | response = client.post( 48 | admin_login_url, 49 | data={"username": "user", "password": "secret", "this_is_the_login_form": "1", "next": "/admin/"}, 50 | HTTP_USER_AGENT="TestUA/1.1", 51 | ) 52 | assert response.url == "/admin/" 53 | session = Session.objects.get(pk=client.cookies[settings.SESSION_COOKIE_NAME].value) 54 | assert user == session.user 55 | 56 | 57 | @pytest.mark.django_db 58 | def test_long_ua(client): 59 | client.get("/modify_session/", HTTP_USER_AGENT="a" * 500) 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: write 7 | checks: write 8 | pull-requests: write 9 | 10 | jobs: 11 | pre-commit: 12 | name: Run pre-commits 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | - uses: pre-commit/action@v3.0.1 18 | 19 | test: 20 | name: Test 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 25 | python-version: ["3.12", "3.11", "3.10"] 26 | django-version: ["5.2", "5.1", "5.0", "4.2"] 27 | include: 28 | - python-version: "3.13" 29 | django-version: "5.2" 30 | - python-version: "3.13" 31 | django-version: "5.1" 32 | - python-version: "3.9" 33 | django-version: "4.2" 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | pip install django~=${{ matrix.django-version }}.0 44 | pip install -e ".[dev]" 45 | - name: Run tests 46 | run: | 47 | set -o pipefail 48 | py.test --cov --junitxml=pytest.xml --cov-report=term-missing:skip-covered | tee pytest-coverage.txt 49 | - if: ${{ strategy.job-index == 0 }} 50 | name: Pytest coverage comment 51 | id: coverageComment 52 | uses: MishaKav/pytest-coverage-comment@main 53 | with: 54 | pytest-coverage-path: ./pytest-coverage.txt 55 | junitxml-path: ./pytest.xml 56 | - if: ${{ strategy.job-index == 0 && github.ref == 'refs/heads/main' }} 57 | name: Create the coverage badge 58 | uses: schneegans/dynamic-badges-action@v1.7.0 59 | with: 60 | auth: ${{ secrets.CODECOVERAGE_GIST }} 61 | gistID: 24a6d63ff9d29d9be5399169f8199ca0 62 | filename: pytest-coverage__${{ github.ref_name }}.json 63 | label: coverage 64 | message: ${{ steps.coverageComment.outputs.coverage }} 65 | color: ${{ steps.coverageComment.outputs.color }} 66 | -------------------------------------------------------------------------------- /qsessions/management/commands/download_geoip_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import tarfile 4 | import urllib.request 5 | 6 | from django.conf import settings 7 | from django.core.management.base import BaseCommand 8 | from django.utils.http import urlencode 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Update GeoIP2 database" 13 | 14 | def add_arguments(self, parser): 15 | default_license_key = os.environ.get("MAXMIND_LICENSE_KEY") 16 | parser.add_argument( 17 | "-k", "--maxmind-license-key", default=default_license_key, required=(not default_license_key) 18 | ) 19 | 20 | def handle(self, maxmind_license_key, verbosity=0, **options): 21 | db_path = getattr(settings, "GEOIP_PATH", None) 22 | if not db_path: 23 | if verbosity >= 1: 24 | self.stderr.write("No GEOIP_PATH defined, not downloading database.") 25 | return 26 | 27 | if not os.path.exists(db_path): 28 | os.makedirs(db_path) 29 | 30 | for basename, url in [ 31 | ("GeoLite2-City.tar.gz", self.get_download_url("GeoLite2-City", maxmind_license_key)), 32 | ("GeoLite2-Country.tar.gz", self.get_download_url("GeoLite2-Country", maxmind_license_key)), 33 | ]: 34 | filename = os.path.join(db_path, basename) 35 | if verbosity >= 1: 36 | redacted_url = re.sub("license_key=([^&]+)", "license_key=...", url) 37 | self.stdout.write(f"Downloading and extracting {redacted_url}...") 38 | urllib.request.urlretrieve(url, filename) 39 | self.extract_tar(db_path, filename, verbosity) 40 | os.remove(filename) 41 | 42 | @staticmethod 43 | def get_download_url(edition_id, maxmind_license_key): 44 | return "https://download.maxmind.com/app/geoip_download?%s" % urlencode( 45 | {"edition_id": edition_id, "license_key": maxmind_license_key, "suffix": "tar.gz"} 46 | ) 47 | 48 | def extract_tar(self, db_path, tar_path, verbosity): 49 | with tarfile.open(tar_path) as tarball: 50 | for tarinfo in tarball: 51 | if tarinfo.name.endswith(".mmdb"): 52 | tarinfo.name = os.path.basename(tarinfo.name) 53 | tarball.extract(tarinfo, path=db_path) 54 | if verbosity >= 2: 55 | dest_path = os.path.join(db_path, tarinfo.name) 56 | self.stdout.write(f" => {dest_path}") 57 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib import auth 3 | 4 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY 5 | from qsessions.models import Session 6 | 7 | 8 | @pytest.mark.django_db 9 | def test_get_decoded(SessionStore, django_user_model): 10 | django_user_model.objects.create_user(username="test_user") 11 | 12 | store = SessionStore(user_agent="TestUA/1.1", 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 | assert session.get_decoded() == { 19 | "foo": "bar", 20 | auth.SESSION_KEY: 1, 21 | IP_SESSION_KEY: "127.0.0.1", 22 | USER_AGENT_SESSION_KEY: "TestUA/1.1", 23 | } 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_very_long_ua(SessionStore): 28 | ua = ( 29 | "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; ELT; " 30 | "BTRS29395; Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1) ; " 31 | "Embedded Web Browser from: http://bsalsa.com/; .NET CLR 2.0.50727; " 32 | ".NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022; " 33 | ".NET CLR 1.1.4322; ELT; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; FDM; " 34 | ".NET4.0C; .NET4.0E; ELT)" 35 | ) 36 | store = SessionStore(user_agent=ua, ip="127.0.0.1") 37 | store.save() 38 | 39 | session = Session.objects.get(pk=store.session_key) 40 | assert session.user_agent == ua[:300] 41 | 42 | 43 | def test_location(): 44 | session = Session(ip="89.160.20.112") 45 | assert session.location == "Linköping, Sweden" 46 | loc_info = session.location_info 47 | assert loc_info["city"] == "Linköping" 48 | assert loc_info["country_code"] == "SE" 49 | 50 | # This depends on Django version, so be safe 51 | assert loc_info.get("continent_code") == "EU" or loc_info.get("region") == "E" 52 | 53 | 54 | def test_device(): 55 | session = Session( 56 | user_agent=( 57 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) " 58 | "AppleWebKit/537.36 (KHTML, like Gecko) " 59 | "Chrome/70.0.3538.102 Safari/537.36" 60 | ) 61 | ) 62 | device = session.device_info 63 | assert device.os.family == "Mac OS X" 64 | assert device.user_agent.family == "Chrome" 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_delete(SessionStore): 69 | """ 70 | Session.delete should delete session from both DB and cache 71 | """ 72 | store = SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 73 | store.create() 74 | session_key = store.session_key 75 | 76 | session = Session.objects.get(pk=session_key) 77 | session.delete() 78 | 79 | assert not store.exists(session_key) 80 | 81 | 82 | @pytest.mark.django_db 83 | def test_bulk_delete_from_both_cache_and_db(SessionStore): 84 | s1 = SessionStore(user_agent="Python/2.7", ip="127.0.0.1") 85 | s1.create() 86 | s2 = SessionStore(user_agent="Python/2.7", ip="127.0.0.1") 87 | s2.create() 88 | s3 = SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 89 | s3.create() 90 | assert s1.exists(s1.session_key) 91 | assert s2.exists(s2.session_key) 92 | assert s3.exists(s3.session_key) 93 | 94 | Session.objects.filter(user_agent="Python/2.7").delete() 95 | 96 | assert not s1.exists(s1.session_key) 97 | assert not s2.exists(s2.session_key) 98 | assert s3.exists(s3.session_key) 99 | -------------------------------------------------------------------------------- /qsessions/admin.py: -------------------------------------------------------------------------------- 1 | from pprint import pformat 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth import get_user_model 5 | from django.urls import reverse 6 | from django.utils.html import format_html 7 | from django.utils.timezone import now 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from .models import Session 11 | 12 | 13 | def linkify(field_name): 14 | """ 15 | Converts a foreign key value into clickable links. 16 | """ 17 | 18 | def _linkify(obj): 19 | linked_obj = getattr(obj, field_name) 20 | if linked_obj is None: 21 | return "-" 22 | app_label = linked_obj._meta.app_label 23 | model_name = linked_obj._meta.model_name 24 | view_name = f"admin:{app_label}_{model_name}_change" 25 | link_url = reverse(view_name, args=[linked_obj.pk]) 26 | return format_html('{}', link_url, linked_obj) 27 | 28 | _linkify.short_description = field_name # Sets column name 29 | return _linkify 30 | 31 | 32 | class ExpiredFilter(admin.SimpleListFilter): 33 | title = _("Is Valid") 34 | parameter_name = "active" 35 | 36 | def lookups(self, request, model_admin): 37 | return [("1", _("Active")), ("0", _("Expired"))] 38 | 39 | def queryset(self, request, queryset): 40 | if self.value() == "1": 41 | return queryset.filter(expire_date__gt=now()) 42 | elif self.value() == "0": 43 | return queryset.filter(expire_date__lte=now()) 44 | 45 | 46 | class OwnerFilter(admin.SimpleListFilter): 47 | title = _("Owner") 48 | parameter_name = "owner" 49 | 50 | def lookups(self, request, model_admin): 51 | return [("my", _("Self"))] 52 | 53 | def queryset(self, request, queryset): 54 | if self.value() == "my": 55 | return queryset.filter(user=request.user) 56 | 57 | 58 | @admin.register(Session) 59 | class SessionAdmin(admin.ModelAdmin): 60 | list_display = ("ip", linkify("user"), "is_valid", "created_at", "expire_date", "device", "location") 61 | list_select_related = ("user",) 62 | list_filter = ExpiredFilter, OwnerFilter 63 | fields = ( 64 | "user", 65 | "ip", 66 | "location", 67 | "is_valid", 68 | "created_at", 69 | "updated_at", 70 | "expire_date", 71 | "user_agent", 72 | "device", 73 | "session_key", 74 | "session_data_decoded", 75 | ) 76 | ordering = ("-expire_date",) 77 | 78 | def get_search_fields(self, request): 79 | # noinspection PyPep8Naming 80 | User = get_user_model() 81 | return ( 82 | "ip", 83 | f"user__{getattr(User, 'USERNAME_FIELD', 'username')}", 84 | f"user__{getattr(User, 'USERNAME_EMAIL', 'email')}", 85 | ) 86 | 87 | @admin.display(description=_("Is valid"), boolean=True) 88 | def is_valid(self, obj: Session): 89 | return obj.expire_date > now() 90 | 91 | @admin.display(description=_("Session data")) 92 | def session_data_decoded(self, obj: Session): 93 | return format_html( 94 | '
{}',
95 | pformat(obj.get_decoded()),
96 | )
97 |
98 | def has_add_permission(self, request):
99 | return False
100 |
101 | def has_change_permission(self, request, obj=None):
102 | return False
103 |
--------------------------------------------------------------------------------
/qsessions/models.py:
--------------------------------------------------------------------------------
1 | from functools import cached_property
2 | from importlib import import_module
3 |
4 | from django.conf import settings
5 | from django.contrib.sessions.base_session import AbstractBaseSession, BaseSessionManager
6 | from django.core.cache import caches
7 | from django.db import models
8 | from django.utils import timezone
9 | from django.utils.translation import gettext_lazy as _
10 |
11 | import qsessions.geoip as geoip
12 |
13 |
14 | class SessionQuerySet(models.QuerySet):
15 | def delete(self):
16 | """
17 | Delete sessions from both DB and cache (first cache, then DB)
18 | """
19 | # noinspection PyPep8Naming
20 | SessionStore = Session.get_session_store_class()
21 | prefix = getattr(SessionStore, "cache_key_prefix", None)
22 | if prefix is not None:
23 | caches[settings.SESSION_CACHE_ALIAS].delete_many(prefix + s.session_key for s in self)
24 | return super().delete()
25 |
26 |
27 | class SessionManager(BaseSessionManager.from_queryset(SessionQuerySet)):
28 | use_in_migrations = True
29 |
30 |
31 | class Session(AbstractBaseSession):
32 | """
33 | Session objects containing user session information.
34 | """
35 |
36 | user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), null=True, on_delete=models.CASCADE)
37 | user_agent = models.CharField(null=True, blank=True, max_length=300)
38 | created_at = models.DateTimeField(null=True)
39 | updated_at = models.DateTimeField(auto_now=True)
40 | ip = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP"))
41 |
42 | objects = SessionManager()
43 |
44 | @classmethod
45 | def get_session_store_class(cls):
46 | return import_module(settings.SESSION_ENGINE).SessionStore
47 |
48 | def save(self, *args, **kwargs):
49 | # FIXME: find a better solution for `created_at` field which does not need an extra query.
50 | # https://code.djangoproject.com/ticket/17654
51 | try:
52 | self.created_at = Session.objects.get(pk=self.pk).created_at
53 | except Session.DoesNotExist:
54 | self.created_at = timezone.now()
55 | super().save(*args, **kwargs)
56 |
57 | def delete(self, *args, **kwargs):
58 | """
59 | Delete session from both DB and cache (first cache, then DB)
60 | """
61 | # noinspection PyPep8Naming
62 | SessionStore = Session.get_session_store_class()
63 | prefix = getattr(SessionStore, "cache_key_prefix", None)
64 | if prefix is not None:
65 | caches[settings.SESSION_CACHE_ALIAS].delete(prefix + self.session_key)
66 | return super().delete(*args, **kwargs)
67 |
68 | @cached_property
69 | def location_info(self) -> dict:
70 | return geoip.ip_to_location_info(self.ip)
71 |
72 | @cached_property
73 | def location(self) -> str:
74 | return geoip.ip_to_location(self.ip)
75 |
76 | @cached_property
77 | def device_info(self):
78 | """
79 | Describe the user agent of this session, if any
80 | :rtype: ua_parser.core.Result | None
81 | """
82 | if self.user_agent:
83 | from ua_parser import parse # late import to avoid import cost
84 |
85 | return parse(self.user_agent)
86 | return None
87 |
88 | @cached_property
89 | def device(self) -> str:
90 | if device := self.device_info:
91 |
92 | def get_version_string(version_info):
93 | try:
94 | return ".".join(version_info[: version_info.index(None)])
95 | except ValueError:
96 | return ".".join(version_info)
97 |
98 | return "{device} / {os} / {browser}".format(
99 | device=device.device.family if device.device else "Other",
100 | os=(
101 | f"{device.os.family} {get_version_string([device.os.major, device.os.minor, device.os.patch, device.os.patch_minor])}"
102 | if device.os
103 | else "Other"
104 | ),
105 | browser=(
106 | f"{device.user_agent.family} {get_version_string([device.user_agent.major, device.user_agent.minor, device.user_agent.patch, device.user_agent.patch_minor])}"
107 | if device.user_agent
108 | else "Other"
109 | ),
110 | )
111 | return ""
112 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 2.1.0 (Sep 21, 2025)
2 |
3 | - Add support for Django 5.2.
4 | - Make `geoip2` an optional dependency. It can be installed using the `geoip2` extra.
5 |
6 | # 2.0.0 (Feb 23, 2025)
7 |
8 | - Add support for Python 3.13 and drop support for Python 3.8.
9 |
10 | Backward-incompatible changes:
11 |
12 | - The IP is now read directly from the value of `REMOTE_ADDR`
13 | (instead of relying on **django-ipware**)
14 | for the same reason Django
15 | [removed](https://docs.djangoproject.com/en/5.2/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware)
16 | `SetRemoteAddrFromForwardedFor` middleware in 1.1.
17 | If you are using a reverse proxy,
18 | you should configure it
19 | to pass the real IP address in the `REMOTE_ADDR` header,
20 | or you can write a custom version of
21 | [`SetRemoteAddrFromForwardedFor` middleware](https://github.com/django/django/blob/91f18400cc0fb37659e2dbaab5484ff2081f1f30/django/middleware/http.py#L33)
22 | which suits your environment.
23 | - `session.location` and `session.location_info` are now properties
24 | instead of methods.
25 | - `session.device` is now a property instead of a method and returns string.
26 | - The device object can be accessed using the new property `session.device_info`.
27 | - User agent parsing is now done using `ua-parser` instead of `user-agents`.
28 | - The device object is now an instance of `ua_parser.core.Result`
29 | instead of `user_agents.parsers.UserAgent`.
30 |
31 |
32 | # 1.1.5 (Jun 22, 2024)
33 |
34 | - Add support for Python 3.12 and drop support for Python 3.7.
35 | - Add support for Django 4.2, 5.0 and drop support for Django 3.2, 4.1.
36 | - Fix a bug in admin ("add" and "edit" session).
37 | - **Nov 3, 2024:** Add support for Django 5.1.
38 |
39 | Thanks [@ataylor32](https://github.com/ataylor32), [@browniebroke](https://github.com/browniebroke)
40 |
41 | # 1.1.4 (Sep 11, 2022)
42 |
43 | - Add Django 4.1 support.
44 | - Drop support for Python 3.6.
45 | - Drop support for Django 2.2, 3.0, 3.1.
46 |
47 | Thanks [@akx](https://github.com/akx)
48 |
49 | # 1.1.3 (Dec 24, 2021)
50 |
51 | - Add Django 4.0 support.
52 | - Drop support for Django 1.11, 2.0, 2.1.
53 |
54 | # 1.1.2 (Oct 17, 2020)
55 |
56 | - Use gettext_lazy instead of ugettext_lazy.
57 |
58 | Thanks [@akx](https://github.com/akx)
59 |
60 | # 1.1.1 (Sep 10, 2020)
61 |
62 | - Set development status to Production/Stable in setup.py.
63 |
64 | # 1.1.0 (Sep 9, 2020)
65 |
66 | - Link to user in admin page.
67 |
68 | Thanks [@YazdanRa](https://github.com/YazdanRa)
69 |
70 | # 1.0.1 (Sep 9, 2020)
71 |
72 | - Fix N+1 problem in admin page by adding `user` to `select_related`.
73 | - Update MANIFEST.in
74 |
75 | Thanks [@jayvdb](https://github.com/jayvdb)
76 |
77 | # 1.0.0 (Aug 19, 2020)
78 |
79 | I think everything is OK for releasing `1.0.0` since django-qsessions is working fine in production for long time.
80 |
81 | - Drop support for Django 1.10.
82 | - Drop support for Python 3.5, since its end of life is near. Plus, maxminddb doesn't support 3.5 anymore.
83 | - Add Django 3.1 to support matrix.
84 |
85 | # 0.5.0 (Jul 2, 2020)
86 |
87 | - Drop support for Python 2.
88 | - Use `ipware.get_client_ip` instead of `ipware.ip.get_real_ip` (which is removed since `django-ipware==3.0.0`)
89 | - Format source code using [black](https://github.com/psf/black)
90 |
91 | Thanks [@sevdog](https://github.com/sevdog)
92 |
93 | # 0.4.1 (Jan 21, 2020)
94 |
95 | - Updated `download_geoip_db` management command to use new Maxmind download URLs, and provide license key.
96 |
97 | Thanks [@akx](https://github.com/akx)
98 |
99 | # 0.4.0 (Jan 21, 2020)
100 |
101 | - Added Django 3.0 to support matrix.
102 | - Removed Python 3.4 from support matrix.
103 |
104 | # 0.3.0 (Nov 2, 2019)
105 |
106 | - Added `qsessions.backends.db` session backend.
107 |
108 | Thanks [@willstott101](https://github.com/willstott101)
109 |
110 | # 0.2.1 (May 8, 2019)
111 |
112 | - Added support for Django 2.2.
113 |
114 | Thanks [@akx](https://github.com/akx)
115 |
116 | # 0.2.0 (Dec 25, 2018)
117 |
118 | - Added support for Python 3.7, Django 2.1.
119 | - Used pytest for testing.
120 | - Improved session delete performance (reduce number of queries)
121 | - Refactored codes
122 |
123 | Thanks [@akx](https://github.com/akx), [@saeed617](https://github.com/saeed617)
124 |
125 | # 0.1.6 (Jun 18, 2018)
126 |
127 | - Improve docs
128 |
129 | # 0.1.5 (May 15, 2018)
130 |
131 | - Fixed a bug when User Agent is an empty string
132 |
133 | # 0.1.4 (Feb 5, 2018)
134 |
135 | - Fixed migrations for `created_at` field.
136 |
--------------------------------------------------------------------------------
/tests/test_sessionstore.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | import pytest
4 | from django.conf import settings
5 | from django.contrib import auth
6 | from django.contrib.sessions.backends.base import CreateError
7 | from django.utils.timezone import now
8 |
9 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY
10 | from qsessions.models import Session
11 |
12 |
13 | @pytest.fixture(name="store")
14 | def setup_store(SessionStore):
15 | return SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1")
16 |
17 |
18 | def test_untouched_init(store):
19 | assert store.modified is False
20 | assert store.accessed is False
21 | assert store.get("cat") is None
22 |
23 |
24 | def test_store(store):
25 | store["cat"] = "dog"
26 | assert store.accessed is True
27 | assert store.modified is True
28 | assert "cat" in store
29 | assert store.pop("cat") == "dog"
30 | assert "cat" not in store
31 | assert store.get("cat") is None
32 |
33 |
34 | def test_auth_session_key(store):
35 | assert auth.SESSION_KEY not in store
36 | assert store.modified is False
37 | assert store.accessed is True
38 |
39 | store.get(auth.SESSION_KEY)
40 | assert store.modified is False
41 |
42 | store[auth.SESSION_KEY] = 1
43 | assert store.modified is True
44 |
45 |
46 | @pytest.mark.django_db
47 | def test_save(store, django_user_model):
48 | django_user_model.objects.create_user(username="test_user")
49 |
50 | store[auth.SESSION_KEY] = 1
51 | store.save()
52 |
53 | session = Session.objects.get(pk=store.session_key)
54 | assert session.user_agent == "TestUA/1.1"
55 | assert session.ip == "127.0.0.1"
56 | assert session.user_id == 1
57 | assert now() - timedelta(seconds=5) <= session.updated_at <= now()
58 |
59 |
60 | @pytest.mark.django_db
61 | def test_load_unmodified(SessionStore, store, django_user_model):
62 | django_user_model.objects.create_user(username="test_user")
63 |
64 | store[auth.SESSION_KEY] = 1
65 | store.save()
66 | store2 = SessionStore(session_key=store.session_key, user_agent="TestUA/1.1", ip="127.0.0.1")
67 | store2.load()
68 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1"
69 | assert store2.get(IP_SESSION_KEY) == "127.0.0.1"
70 | assert store2.get(auth.SESSION_KEY) == 1
71 | assert store2.modified is False
72 |
73 |
74 | @pytest.mark.django_db
75 | def test_load_modified(SessionStore, store, django_user_model):
76 | django_user_model.objects.create_user(username="test_user")
77 |
78 | store[auth.SESSION_KEY] = 1
79 | store.save()
80 | store2 = SessionStore(session_key=store.session_key, user_agent="TestUA/1.1-changed", ip="8.8.8.8")
81 | store2.load()
82 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1"
83 | assert store2.get(IP_SESSION_KEY) == "127.0.0.1"
84 | assert store2.get(auth.SESSION_KEY) == 1
85 | assert store2.modified is True
86 |
87 | store2.save()
88 |
89 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1-changed"
90 | assert store2.get(IP_SESSION_KEY) == "8.8.8.8"
91 |
92 |
93 | @pytest.mark.django_db
94 | def test_duplicate_create(SessionStore):
95 | s1 = SessionStore(session_key="DUPLICATE", user_agent="TestUA/1.1", ip="127.0.0.1")
96 | s1.create()
97 | s2 = SessionStore(session_key="DUPLICATE", user_agent="TestUA/1.1", ip="127.0.0.1")
98 | s2.create()
99 | assert s1.session_key != s2.session_key
100 |
101 | s3 = SessionStore(session_key=s1.session_key, user_agent="TestUA/1.1", ip="127.0.0.1")
102 | with pytest.raises(CreateError):
103 | s3.save(must_create=True)
104 |
105 |
106 | @pytest.mark.django_db
107 | def test_delete(store):
108 | # not persisted, should just return
109 | store.delete()
110 |
111 | # create, then delete
112 | store.create()
113 | session_key = store.session_key
114 | store.delete()
115 |
116 | # non-existing sessions, should not raise
117 | store.delete()
118 | store.delete(session_key)
119 |
120 |
121 | @pytest.mark.django_db
122 | def test_clear(store):
123 | """
124 | Clearing the session should clear all non-browser information
125 | """
126 | store[auth.SESSION_KEY] = 1
127 | store.clear()
128 | store.save()
129 |
130 | session = Session.objects.get(pk=store.session_key)
131 | assert session.user_id is None
132 |
133 |
134 | def test_import(SessionStore):
135 | if settings.SESSION_ENGINE.endswith(".cached_db"):
136 | from qsessions.backends.cached_db import SessionStore as CachedDBBackend
137 |
138 | assert issubclass(SessionStore, CachedDBBackend)
139 | elif settings.SESSION_ENGINE.endswith(".db"):
140 | from qsessions.backends.db import SessionStore as DBBackend
141 |
142 | assert issubclass(SessionStore, DBBackend)
143 | else:
144 | assert False, "Unrecognised Session Engine"
145 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django QSessions
2 |
3 | [](https://pypi.python.org/pypi/django-qsessions/)
4 | [](https://github.com/QueraTeam/django-qsessions/actions)
5 | [](https://github.com/QueraTeam/django-qsessions/actions)
6 | [](https://github.com/QueraTeam/django-qsessions/blob/master/LICENSE.txt)
7 | [](https://github.com/psf/black)
8 |
9 | **django-qsessions** offers two extended session backends for Django.
10 | They extend Django's `db` and `cached_db` backends (and `Session` model)
11 | with following extra features:
12 |
13 | - Sessions have a foreign key to User
14 | - Sessions store IP and User Agent
15 |
16 | These features help you implement "Session Management" and show a list
17 | of active sessions to the user. You can display IP, location and user
18 | agent for each session and add an option to revoke sessions.
19 |
20 | ## Comparison
21 |
22 | Here is a brief comparison between Django's session backends (db, cache,
23 | cached_db), and django-qsessions.
24 |
25 | | 29 | | django | 30 |qsessions | 31 ||||
|---|---|---|---|---|---|
| cache | 34 |db | 35 |cached_db | 36 |db | 37 |cached_db | 38 ||
| Performance | 43 |✔✔ | 44 |45 | | ✔ | 46 |47 | | ✔ | 48 | 49 |
| Persistence | 51 |52 | | ✔ | 53 |✔ | 54 |✔ | 55 |✔ | 56 |
| Foreign Key to User | 59 |60 | | 61 | | 62 | | ✔ | 63 |✔ | 64 |
| Store IP and User Agent | 67 |68 | | 69 | | 70 | | ✔ | 71 |✔ | 72 |