├── demo ├── __init__.py ├── apps.py ├── templates │ ├── index.html │ ├── visitors │ │ ├── self_service_success.html │ │ └── self_service_request.html │ └── bar.html ├── signals.py ├── urls.py ├── views.py └── settings.py ├── visitors ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0006_alter_visitor_uuid.py │ ├── 0005_visitorlog_status_code.py │ ├── 0003_visitor_is_active.py │ ├── 0007_visitor_session_expiry.py │ ├── 0004_visitor_expires_at.py │ ├── 0002_visitorlog.py │ └── 0001_initial.py ├── apps.py ├── signals.py ├── exceptions.py ├── templates │ └── visitors │ │ ├── self_service_request.html │ │ └── self_service_success.html ├── urls.py ├── forms.py ├── session.py ├── settings.py ├── admin.py ├── middleware.py ├── decorators.py ├── views.py └── models.py ├── pytest.ini ├── poetry.toml ├── .coveragerc ├── .gitignore ├── tests ├── __init__.py ├── urls.py ├── views.py ├── conftest.py ├── test_forms.py ├── settings.py ├── test_models.py ├── test_views.py ├── test_decorators.py └── test_middleware.py ├── .editorconfig ├── manage.py ├── .prettierrc ├── mypy.ini ├── .pre-commit-config.yaml ├── LICENSE ├── CHANGELOG.md ├── tox.ini ├── pyproject.toml ├── .github └── workflows │ └── tox.yml ├── .ruff.toml └── README.md /demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /visitors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /visitors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | tests/* 5 | */migrations/*.py 6 | .venv/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox 3 | .venv 4 | .vscode 5 | *.bak 6 | *.egg-info 7 | *.pyc 8 | demo.db 9 | dist 10 | htmlcov 11 | node_modules 12 | poetry.lock 13 | test.db 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # The package contains the tests for the packaged app, along 2 | # with a standalone `test_app` that contains a barebones Django 3 | # app that can be used for testing the local webserver. 4 | -------------------------------------------------------------------------------- /visitors/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VisitorsConfig(AppConfig): 5 | name = "visitors" 6 | verbose_name = "Visitor passes" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /visitors/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # sent when a user creates their own Visitor - can 4 | # be used to send the email with the token 5 | # kwargs: visitor 6 | self_service_visitor_created = Signal() 7 | -------------------------------------------------------------------------------- /demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | name = "demo" 6 | 7 | def ready(self) -> None: 8 | from demo import signals # noqa: F401 9 | 10 | return super().ready() 11 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("visitors", include("visitors.urls")), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | 3 | from visitors.decorators import user_is_visitor 4 | 5 | 6 | @user_is_visitor(scope="foo") 7 | def foo(request: HttpRequest) -> HttpResponse: 8 | return HttpResponse("OK") 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yaml] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /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", "demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /visitors/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | 3 | 4 | class InvalidVisitorPass(Exception): 5 | pass 6 | 7 | 8 | class VisitorAccessDenied(PermissionDenied): 9 | """Error raised by decorator when visitor is invalid.""" 10 | 11 | def __init__(self, msg: str, scope: str) -> None: 12 | super().__init__(msg) 13 | self.scope = scope 14 | -------------------------------------------------------------------------------- /demo/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Django Visitor Pass - Demo Site

7 |

8 |

9 |

13 |

14 | 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto", 12 | "overrides": [ 13 | { 14 | "files": "*.yaml,*.yml", 15 | "options": { "tabWidth": 2 } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /visitors/templates/visitors/self_service_request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Django Visitor Pass

5 |

Missing Template

6 |

7 | If you are seeing this page, you are missing the "visitors/self_service_request.html" 8 | template. You must provide your own template. 9 |

10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /visitors/templates/visitors/self_service_success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Django Visitor Pass

5 |

Missing Template

6 |

7 | If you are seeing this page, you are missing the "visitors/self_service_success.html" 8 | template. You must provide your own template. 9 |

10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /visitors/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "visitors" 6 | 7 | urlpatterns = [ 8 | path( 9 | "self-service//", 10 | views.SelfServiceRequest.as_view(), 11 | name="self-service", 12 | ), 13 | path( 14 | "self-service//success/", 15 | views.SelfServiceSuccess.as_view(), 16 | name="self-service-success", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /demo/signals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import cast 3 | 4 | from django.dispatch import receiver 5 | 6 | from visitors.models import Visitor 7 | from visitors.signals import self_service_visitor_created 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @receiver(self_service_visitor_created) 13 | def send_visitor_notification(sender: object, **kwargs: object) -> None: 14 | visitor = cast(Visitor, kwargs["visitor"]) 15 | logger.info(f"Sending visitor pass to: {visitor.email}") 16 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict_optional=True 3 | ignore_missing_imports=True 4 | follow_imports=silent 5 | warn_redundant_casts=True 6 | warn_unused_ignores = true 7 | warn_unreachable = true 8 | disallow_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | 11 | # Disable mypy for migrations 12 | [mypy-*.migrations.*] 13 | ignore_errors=True 14 | 15 | # Disable mypy for settings 16 | [mypy-*.settings.*] 17 | ignore_errors=True 18 | 19 | # Disable mypy for tests 20 | [mypy-tests.*] 21 | ignore_errors=True 22 | -------------------------------------------------------------------------------- /visitors/migrations/0006_alter_visitor_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-24 12:08 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("visitors", "0005_visitorlog_status_code"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="visitor", 16 | name="uuid", 17 | field=models.UUIDField(default=uuid.uuid4, unique=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | import demo.views 5 | from visitors import urls as visitor_urls 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = [ 10 | path("admin/", admin.site.urls), 11 | path("foo/", demo.views.foo, name="foo"), 12 | path("bar/", demo.views.bar, name="bar"), 13 | path("logout/", demo.views.logout, name="logout"), 14 | path("visitors/", include(visitor_urls, namespace="visitors")), 15 | path("", demo.views.index, name="index"), 16 | ] 17 | -------------------------------------------------------------------------------- /visitors/migrations/0005_visitorlog_status_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-13 15:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0004_visitor_expires_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitorlog", 14 | name="status_code", 15 | field=models.PositiveIntegerField(default=0, verbose_name="HTTP Response"), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | 4 | from visitors.models import Visitor 5 | 6 | 7 | @pytest.fixture 8 | def visitor() -> Visitor: 9 | return Visitor.objects.create(email="fred@example.com", scope="foo") 10 | 11 | 12 | @pytest.fixture 13 | def temp_visitor() -> Visitor: 14 | return Visitor.objects.create_temp_visitor( 15 | scope="foo", 16 | redirect_to="/foo", 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def user() -> User: 22 | return User.objects.create(username="Fred") 23 | -------------------------------------------------------------------------------- /visitors/migrations/0003_visitor_is_active.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2021-01-03 12:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0002_visitorlog"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitor", 14 | name="is_active", 15 | field=models.BooleanField( 16 | default=True, 17 | help_text="Set to False to disable the visitor link and prevent further access.", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /demo/templates/visitors/self_service_success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Django Visitor Pass

4 |

Self-Service demonstration

5 |

A new visitor pass has been created for you.

6 |

7 | In a production environment you should connect to the `self_service_visitor_created` 8 | signal and send the visitor pass to end user. 9 |

10 |

11 | Link: 12 | {{ visitor.context.redirect_to }}?vuid={{ visitor.uuid }} 13 |

14 | 15 | 16 | -------------------------------------------------------------------------------- /visitors/migrations/0007_visitor_session_expiry.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-26 09:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0006_alter_visitor_uuid"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitor", 14 | name="session_expiry", 15 | field=models.PositiveIntegerField( 16 | default=0, 17 | help_text="Time in seconds after which visitor session should expire.", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /visitors/migrations/0004_visitor_expires_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2021-01-03 12:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0003_visitor_is_active"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitor", 14 | name="expires_at", 15 | field=models.DateTimeField( 16 | blank=True, 17 | help_text="After this time the link can no longer be used - defaults to VISITOR_TOKEN_EXPIRY.", 18 | null=True, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /visitors/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.utils.translation import gettext as _ 4 | 5 | 6 | class SelfServiceForm(forms.Form): 7 | """Form for capturing user info.""" 8 | 9 | first_name = forms.CharField(max_length=150) 10 | last_name = forms.CharField(max_length=150) 11 | email = forms.EmailField(required=True) 12 | vuid = forms.CharField(widget=forms.HiddenInput()) 13 | 14 | def clean_email(self) -> str: 15 | email = self.cleaned_data["email"] 16 | if email.endswith("example.com"): 17 | raise ValidationError( 18 | _("Invalid email - you cannot use the example.com domain") 19 | ) 20 | return email 21 | -------------------------------------------------------------------------------- /visitors/session.py: -------------------------------------------------------------------------------- 1 | from django.http.request import HttpRequest 2 | 3 | from visitors.settings import VISITOR_SESSION_KEY 4 | 5 | 6 | def stash_visitor_uuid(request: HttpRequest) -> None: 7 | """Store request visitor data in session.""" 8 | request.session[VISITOR_SESSION_KEY] = request.visitor.session_data 9 | if request.user.is_anonymous: 10 | request.session.set_expiry(request.visitor.session_expiry) 11 | 12 | 13 | def get_visitor_uuid(request: HttpRequest) -> str: 14 | """Return visitor data from session.""" 15 | return request.session.get(VISITOR_SESSION_KEY, "") 16 | 17 | 18 | def clear_visitor_uuid(request: HttpRequest) -> None: 19 | """Remove visitor data from session.""" 20 | request.session.pop(VISITOR_SESSION_KEY, "") 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: "v0.11.13" 5 | hooks: 6 | - id: ruff-format 7 | args: [format] 8 | 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | # Ruff version. 11 | rev: "v0.11.13" 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | 16 | # python static type checking 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: v1.16.0 19 | hooks: 20 | - id: mypy 21 | args: 22 | - --disallow-untyped-defs 23 | - --disallow-incomplete-defs 24 | - --check-untyped-defs 25 | - --no-implicit-optional 26 | - --ignore-missing-imports 27 | - --follow-imports=silent 28 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import uuid4 4 | 5 | import pytest 6 | 7 | from visitors.forms import SelfServiceForm 8 | 9 | 10 | class TestSelfServiceForm: 11 | @pytest.mark.parametrize( 12 | "first_name,last_name,email,vuid,is_valid", 13 | [ 14 | ("", "", "", None, False), 15 | ("John", "", "", None, False), 16 | ("John", "Doe", "", None, False), 17 | ("John", "Doe", "john@doe.com", None, False), 18 | ("John", "Doe", "john@doe.com", uuid4(), True), 19 | ("John", "Doe", "john.doe@example.com", uuid4(), False), 20 | ], 21 | ) 22 | def test_form(self, first_name, last_name, email, vuid, is_valid): 23 | form = SelfServiceForm( 24 | { 25 | "first_name": first_name, 26 | "last_name": last_name, 27 | "email": email, 28 | "vuid": vuid, 29 | } 30 | ) 31 | assert form.is_valid() == is_valid 32 | -------------------------------------------------------------------------------- /demo/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | from django.http.response import HttpResponseRedirect 3 | from django.shortcuts import render 4 | from django.urls.base import reverse 5 | 6 | from visitors.decorators import user_is_visitor 7 | 8 | 9 | def index(request: HttpRequest) -> HttpResponse: 10 | """Display the homepage.""" 11 | return render(request, template_name="index.html") 12 | 13 | 14 | # Test view that requires a valid Visitor Pass 15 | @user_is_visitor(scope="foo") 16 | def foo(request: HttpRequest) -> HttpResponse: 17 | return HttpResponse("OK") 18 | 19 | 20 | # Test view that supports self-service 21 | @user_is_visitor(scope="bar", self_service=True, self_service_session_expiry=60) 22 | def bar(request: HttpRequest) -> HttpResponse: 23 | return render(request, template_name="bar.html") 24 | 25 | 26 | # Deactivate any current visitor token - this is analagous 27 | # to "logging out" an authenticated user. 28 | def logout(request: HttpRequest) -> HttpResponse: 29 | if request.user.is_visitor: 30 | request.visitor.deactivate() 31 | return HttpResponseRedirect(reverse("bar")) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 YunoJuno Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## v1.1 6 | 7 | * Add support for Django 5.2 8 | * Add support for Python 3.13 9 | * Drop support for Django versions before 4.2 10 | * Drop support for Python 3.8 and 3.9 11 | * Update ruff commands to use modern syntax 12 | * Update GitHub Actions to only test Django main branch with Python 3.12 and 3.13 13 | * Drop `black` in favor for `ruff` 14 | 15 | ## v1.0 16 | 17 | * Add support for Django 5.0 18 | * Add support for Python 3.11, 3.12 19 | * Drop support for Django 3.1 20 | * Replace isort, flake8 with ruff 21 | 22 | ## v0.7 23 | 24 | * Add support for per-token session expiry (#9) 25 | 26 | ## v0.5 27 | 28 | * Return `HttpReponseBadRequest` (400) on malformed UUID token, h/t @chrisapplegate, issue #7 29 | ## v0.4 30 | 31 | * Add support for self-service tokens 32 | ## v0.2 33 | 34 | * Add `Visitor.expires_at` timestamp to manage expiry 35 | * Add `Visitor.is_active` switch to allow instant disabling of visitor passes 36 | * Add `Visitor.validate()` method to validate pass 37 | * Add `VISITOR_TOKEN_EXPIRY` setting (default: 300s) 38 | * Add `VISITOR_SESSION_EXPIRY` settings (default: 0s - expires on browse close) 39 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | fmt, lint, mypy, 5 | django-checks, 6 | ; https://docs.djangoproject.com/en/5.2/releases/ 7 | django42-py{310,311,312} 8 | django50-py{310,311,312} 9 | django52-py{310,311,312,313} 10 | djangomain-py{312,313} 11 | 12 | [testenv] 13 | deps = 14 | coverage 15 | pytest 16 | pytest-cov 17 | pytest-django 18 | django42: Django>=4.2,<4.3 19 | django50: Django>=5.0,<5.1 20 | django52: Django>=5.2,<5.3 21 | djangomain: https://github.com/django/django/archive/main.tar.gz 22 | 23 | commands = 24 | pytest --cov=visitors --verbose tests/ 25 | 26 | [testenv:django-checks] 27 | description = Django system checks and missing migrations 28 | deps = Django 29 | commands = 30 | python manage.py check --fail-level WARNING 31 | python manage.py makemigrations --dry-run --check --verbosity 3 32 | 33 | [testenv:fmt] 34 | description = Python source code formatting (ruff) 35 | deps = 36 | ruff 37 | 38 | commands = 39 | ruff format visitors 40 | 41 | [testenv:lint] 42 | description = Python source code linting (ruff) 43 | deps = 44 | ruff 45 | 46 | commands = 47 | ruff check --fix visitors 48 | 49 | [testenv:mypy] 50 | description = Python source code type hints (mypy) 51 | deps = 52 | mypy 53 | 54 | commands = 55 | mypy visitors 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-visitor-pass" 3 | version = "1.1" 4 | description = "Django app for managing temporary session-based users." 5 | license = "MIT" 6 | authors = ["YunoJuno "] 7 | maintainers = ["YunoJuno "] 8 | readme = "README.md" 9 | homepage = "https://github.com/yunojuno/django-visitor-pass" 10 | repository = "https://github.com/yunojuno/django-visitor-pass" 11 | documentation = "https://github.com/yunojuno/django-visitor-pass" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Web Environment", 15 | "Framework :: Django", 16 | "Framework :: Django :: 4.2", 17 | "Framework :: Django :: 5.0", 18 | "Framework :: Django :: 5.2", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | ] 27 | packages = [{ include = "visitors" }] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.10" 31 | django = "^4.2 || ^5.0 || ^5.2" 32 | 33 | [tool.poetry.dev-dependencies] 34 | coverage = "*" 35 | mypy = "*" 36 | pre-commit = "*" 37 | pytest = "*" 38 | pytest-cov = "*" 39 | pytest-django = "*" 40 | ruff = "*" 41 | tox = "*" 42 | 43 | [build-system] 44 | requires = ["poetry>=0.12"] 45 | build-backend = "poetry.masonry.api" 46 | -------------------------------------------------------------------------------- /demo/templates/visitors/self_service_request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 |
12 |

Access Denied

13 |

Self-Service demonstration

14 |

15 | You have been denied access to a page that requires a Visitor Pass. This page is 16 | marked as 'self-service', which means that you can grant yourself access by filling 17 | in the details below. 18 |

19 | {% if DEBUG %} 20 |

21 | Running in DEBUG mode this demo does not send out email notifications once the 22 | visitor pass is created, but will instead display a success page. In production you 23 | should handle the `self_service_visitor_created` signal to send out the email. 24 |

25 |

26 | NB in production the visitor pass will be emailed to you, as we require confirmation 27 | that you are the owner of the email address. 28 |

29 | {% endif %} 30 |
31 | {% csrf_token %} {{ form }} 32 | 33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /visitors/migrations/0002_visitorlog.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-23 18:42 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("visitors", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="VisitorLog", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("session_key", models.CharField(blank=True, max_length=40)), 27 | ("http_method", models.CharField(max_length=10)), 28 | ("request_uri", models.URLField()), 29 | ("remote_addr", models.CharField(max_length=100)), 30 | ("query_string", models.TextField(blank=True)), 31 | ("http_user_agent", models.TextField()), 32 | ("http_referer", models.TextField()), 33 | ("timestamp", models.DateTimeField(default=django.utils.timezone.now)), 34 | ( 35 | "visitor", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | related_name="visits", 39 | to="visitors.visitor", 40 | ), 41 | ), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /visitors/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | 5 | 6 | def _setting(key: str, default: str) -> str: 7 | return getattr(settings, key, default) 8 | 9 | 10 | # session key used to store visitor.uuid 11 | VISITOR_SESSION_KEY: str = _setting("VISITOR_SESSION_KEY", "visitor:session") 12 | 13 | # key used to store visitor uuid on querystring 14 | VISITOR_QUERYSTRING_KEY: str = _setting("VISITOR_QUERYSTRING_KEY", "vuid") 15 | 16 | # Value used to `request.session.set_expiry` for anonymous users. If the 17 | # request.user is already authenticated we let them carry on with the site 18 | # default - however if the visitor is anonymous we can override the session 19 | # expiry. By default this will be set to 0, which means that the session expires 20 | # when the browser is closed. We do not support datetime or timedelta values 21 | # here - it must be an integer (seconds), or None. see 22 | # https://docs.djangoproject.com/en/3.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry # noqa 23 | # * If value is an integer, the session will expire after that many seconds of 24 | # inactivity. 25 | # * If value is None, the session reverts to using the global session expiry 26 | # policy. 27 | VISITOR_SESSION_EXPIRY: int | None = _setting("VISITOR_SESSION_EXPIRY", 0) 28 | 29 | # Value used to set the expiry of the visitor token - the point at which it can 30 | # no longer be used. NB this is separate from the session expiry. Once the token 31 | # is stashed in the session the visitor will remain a visitor until the session 32 | # expires. This value is used by the VisitorRequestMiddleware. 33 | VISITOR_TOKEN_EXPIRY: int = _setting("VISITOR_TOKEN_EXPIRY", 300) 34 | -------------------------------------------------------------------------------- /visitors/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-23 17:10 2 | 3 | import uuid 4 | 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Visitor", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("uuid", models.UUIDField(default=uuid.uuid4)), 28 | ("first_name", models.CharField(blank=True, max_length=150)), 29 | ("last_name", models.CharField(blank=True, max_length=150)), 30 | ("email", models.EmailField(db_index=True, max_length=254)), 31 | ( 32 | "scope", 33 | models.CharField( 34 | help_text="Used to map request to view function", max_length=100 35 | ), 36 | ), 37 | ("created_at", models.DateTimeField(default=django.utils.timezone.now)), 38 | ( 39 | "context", 40 | models.JSONField( 41 | blank=True, 42 | help_text="Used to store arbitrary contextual data.", 43 | null=True, 44 | ), 45 | ), 46 | ("last_updated_at", models.DateTimeField(auto_now=True)), 47 | ], 48 | options={ 49 | "verbose_name": "Visitor pass", 50 | "verbose_name_plural": "Visitor passes", 51 | }, 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /demo/templates/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 |

Home

20 |

Hello, {{ request.visitor.first_name }}.

21 |

22 | If you are seeing this page, you have successfully "self-served" and have visitor access 23 | to this page. 24 |

25 |

The details of your visitor pass are as follows:

26 |
27 |
id:
28 |
{{ request.visitor.uuid }}
29 |
First name:
30 |
{{ request.visitor.first_name }}
31 |
Last name:
32 |
{{ request.visitor.last_name }}
33 |
Email:
34 |
{{ request.visitor.email }}
35 |
Scope:
36 |
{{ request.visitor.scope }}
37 |
Created at:
38 |
{{ request.visitor.created_at }}
39 |
Expires at:
40 |
{{ request.visitor.expires_at }}
41 |
Has expired:
42 |
{{ request.visitor.has_expired }}
43 |
Is active:
44 |
{{ request.visitor.is_active }}
45 |
Is valid:
46 |
{{ request.visitor.is_valid }}
47 |
Session expiry:
48 |
{{ request.visitor.session_expiry }}
49 |
Session expires at:
50 |
{{ request.session.get_expiry_date }}
51 |
52 |

Now that you are 'authenticated' as a visitor, you can refresh the page 53 | without the `vuid=` querystring param. To refresh the page, click here.

54 |

To deactivate your visitor token, click here.

55 | 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Python / Django 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | format: 13 | name: Check formatting 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toxenv: [fmt, lint, mypy] 18 | env: 19 | TOXENV: ${{ matrix.toxenv }} 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python (3.12) 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.12" 29 | 30 | - name: Install and run tox 31 | run: | 32 | pip install tox 33 | tox 34 | 35 | checks: 36 | name: Run Django checks 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | toxenv: ["django-checks"] 41 | env: 42 | TOXENV: ${{ matrix.toxenv }} 43 | 44 | steps: 45 | - name: Check out the repository 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Python (3.12) 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.12" 52 | 53 | - name: Install and run tox 54 | run: | 55 | pip install tox 56 | tox 57 | 58 | test: 59 | name: Run tests 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python: ["3.10", "3.11", "3.12", "3.13"] 64 | # build LTS version, next version, HEAD 65 | django: ["42", "50", "52", "main"] 66 | exclude: 67 | - python: "3.10" 68 | django: "main" 69 | - python: "3.11" 70 | django: "main" 71 | - python: "3.13" 72 | django: "42" 73 | - python: "3.13" 74 | django: "50" 75 | 76 | env: 77 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 78 | 79 | steps: 80 | - name: Check out the repository 81 | uses: actions/checkout@v4 82 | 83 | - name: Set up Python ${{ matrix.python }} 84 | uses: actions/setup-python@v4 85 | with: 86 | python-version: ${{ matrix.python }} 87 | 88 | - name: Install and run tox 89 | run: | 90 | pip install tox 91 | tox 92 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = True 5 | USE_TZ = True 6 | USE_L10N = True 7 | 8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}} 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "visitors", 18 | ) 19 | 20 | MIDDLEWARE = [ 21 | # default django middleware 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.middleware.common.CommonMiddleware", 24 | "django.middleware.csrf.CsrfViewMiddleware", 25 | "django.contrib.auth.middleware.AuthenticationMiddleware", 26 | "django.contrib.messages.middleware.MessageMiddleware", 27 | "visitors.middleware.VisitorRequestMiddleware", 28 | "visitors.middleware.VisitorSessionMiddleware", 29 | "visitors.middleware.VisitorDebugMiddleware", 30 | ] 31 | 32 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 33 | 34 | TEMPLATES = [ 35 | { 36 | "BACKEND": "django.template.backends.django.DjangoTemplates", 37 | "DIRS": [path.join(PROJECT_DIR, "templates")], 38 | "APP_DIRS": True, 39 | "OPTIONS": { 40 | "context_processors": [ 41 | "django.contrib.messages.context_processors.messages", 42 | "django.contrib.auth.context_processors.auth", 43 | "django.template.context_processors.request", 44 | ] 45 | }, 46 | } 47 | ] 48 | 49 | STATIC_URL = "/static/" 50 | 51 | SECRET_KEY = "secret" # noqa: S105 52 | 53 | LOGGING = { 54 | "version": 1, 55 | "disable_existing_loggers": False, 56 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 57 | "handlers": { 58 | "console": { 59 | "level": "DEBUG", 60 | "class": "logging.StreamHandler", 61 | "formatter": "simple", 62 | } 63 | }, 64 | "loggers": { 65 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"}, 66 | # 'django': { 67 | # 'handlers': ['console'], 68 | # 'propagate': True, 69 | # 'level': 'WARNING', 70 | # }, 71 | }, 72 | } 73 | 74 | ROOT_URLCONF = "tests.urls" 75 | 76 | if not DEBUG: 77 | raise Exception("This settings file can only be used with DEBUG=True") 78 | -------------------------------------------------------------------------------- /demo/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = True 5 | USE_TZ = True 6 | USE_L10N = True 7 | 8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "demo.db"}} 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "visitors", 18 | "demo", 19 | ) 20 | 21 | MIDDLEWARE = [ 22 | # default django middleware 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | "visitors.middleware.VisitorRequestMiddleware", 29 | "visitors.middleware.VisitorSessionMiddleware", 30 | "visitors.middleware.VisitorDebugMiddleware", 31 | ] 32 | 33 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 34 | 35 | TEMPLATES = [ 36 | { 37 | "BACKEND": "django.template.backends.django.DjangoTemplates", 38 | "DIRS": [path.join(PROJECT_DIR, "templates")], 39 | "APP_DIRS": True, 40 | "OPTIONS": { 41 | "context_processors": [ 42 | "django.contrib.messages.context_processors.messages", 43 | "django.contrib.auth.context_processors.auth", 44 | "django.template.context_processors.request", 45 | ] 46 | }, 47 | } 48 | ] 49 | 50 | STATIC_URL = "/static/" 51 | 52 | SECRET_KEY = "secret" # noqa: S105 53 | 54 | LOGGING = { 55 | "version": 1, 56 | "disable_existing_loggers": False, 57 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 58 | "handlers": { 59 | "console": { 60 | "level": "DEBUG", 61 | "class": "logging.StreamHandler", 62 | "formatter": "simple", 63 | } 64 | }, 65 | "loggers": { 66 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"}, 67 | # 'django': { 68 | # 'handlers': ['console'], 69 | # 'propagate': True, 70 | # 'level': 'WARNING', 71 | # }, 72 | }, 73 | } 74 | 75 | ROOT_URLCONF = "demo.urls" 76 | 77 | if not DEBUG: 78 | raise Exception("This settings file can only be used with DEBUG=True") 79 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 88 2 | indent-width = 4 3 | 4 | [lint] 5 | ignore = [ 6 | "D100", # Missing docstring in public module 7 | "D101", # Missing docstring in public class 8 | "D102", # Missing docstring in public method 9 | "D103", # Missing docstring in public function 10 | "D104", # Missing docstring in public package 11 | "D105", # Missing docstring in magic method 12 | "D106", # Missing docstring in public nested class 13 | "D107", # Missing docstring in __init__ 14 | "D203", # 1 blank line required before class docstring 15 | "D212", # Multi-line docstring summary should start at the first line 16 | "D213", # Multi-line docstring summary should start at the second line 17 | "D404", # First word of the docstring should not be "This" 18 | "D405", # Section name should be properly capitalized 19 | "D406", # Section name should end with a newline 20 | "D407", # Missing dashed underline after section 21 | "D410", # Missing blank line after section 22 | "D411", # Missing blank line before section 23 | "D412", # No blank lines allowed between a section header and its content 24 | "D416", # Section name should end with a colon 25 | "D417", # Missing argument description in the docstring 26 | ] 27 | select = [ 28 | "A", # flake8 builtins 29 | "C9", # mcabe 30 | "D", # pydocstyle 31 | "E", # pycodestyle (errors) 32 | "F", # Pyflakes 33 | "I", # isort 34 | "S", # flake8-bandit 35 | "T2", # flake8-print 36 | "W", # pycodestype (warnings) 37 | ] 38 | 39 | [lint.isort] 40 | combine-as-imports = true 41 | 42 | [lint.mccabe] 43 | max-complexity = 8 44 | 45 | [lint.per-file-ignores] 46 | "*tests/*" = [ 47 | "D205", # 1 blank line required between summary line and description 48 | "D400", # First line should end with a period 49 | "D401", # First line should be in imperative mood 50 | "D415", # First line should end with a period, question mark, or exclamation point 51 | "E501", # Line too long 52 | "E731", # Do not assign a lambda expression, use a def 53 | "S101", # Use of assert detected 54 | "S105", # Possible hardcoded password 55 | "S106", # Possible hardcoded password 56 | "S113", # Probable use of requests call with timeout set to {value} 57 | ] 58 | "*/migrations/*" = [ 59 | "E501", # Line too long 60 | ] 61 | "*/settings.py" = [ 62 | "F403", # from {name} import * used; unable to detect undefined names 63 | "F405", # {name} may be undefined, or defined from star imports: 64 | ] 65 | "*/settings/*" = [ 66 | "F403", # from {name} import * used; unable to detect undefined names 67 | "F405", # {name} may be undefined, or defined from star imports: 68 | ] 69 | -------------------------------------------------------------------------------- /visitors/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | from django.contrib import admin, messages 6 | from django.db.models.query import QuerySet 7 | from django.http.request import HttpRequest 8 | from django.utils.html import format_html 9 | 10 | from .models import Visitor, VisitorLog 11 | 12 | 13 | def pretty_print(data: dict | None) -> str: 14 | """Convert dict into formatted HTML.""" 15 | if data is None: 16 | return "" 17 | pretty = json.dumps(data, sort_keys=True, indent=4, separators=(",", ": ")) 18 | html = pretty.replace(" ", " ").replace("\n", "
") 19 | return format_html("
%s
", html) 20 | 21 | 22 | @admin.register(Visitor) 23 | class VisitorsAdmin(admin.ModelAdmin): 24 | """Admin model for Visitor objects.""" 25 | 26 | def deactivate(self, request: HttpRequest, queryset: QuerySet) -> None: 27 | """Call deactivate on all selected Visitor objects.""" 28 | count = queryset.count() 29 | for obj in queryset: 30 | obj.deactivate() 31 | self.message_user( 32 | request, f"{count} passes have been disabled.", messages.SUCCESS 33 | ) 34 | 35 | deactivate.short_description = "Deactivate selected Visitor passes" # type: ignore 36 | 37 | def reactivate(self, request: HttpRequest, queryset: QuerySet) -> None: 38 | """Reactivate all selected Visitor objects.""" 39 | count = queryset.count() 40 | for obj in queryset: 41 | obj.reactivate() 42 | self.message_user( 43 | request, f"{count} passes have been activated.", messages.SUCCESS 44 | ) 45 | 46 | reactivate.short_description = "Reactivate selected Visitor passes" # type: ignore 47 | 48 | actions = (deactivate, reactivate) 49 | list_filter = ("scope",) 50 | list_display = ( 51 | "scope", 52 | "email", 53 | "created_at", 54 | "expires_at", 55 | "is_active", 56 | "_is_valid", 57 | ) 58 | readonly_fields = ( 59 | "uuid", 60 | "created_at", 61 | "last_updated_at", 62 | "_context", 63 | "is_active", 64 | "expires_at", 65 | "session_expiry", 66 | ) 67 | search_fields = ( 68 | "first_name", 69 | "last_name", 70 | "email", 71 | "scope", 72 | "created_at", 73 | ) 74 | 75 | def _is_valid(self, obj: Visitor) -> bool: 76 | return obj.is_valid 77 | 78 | _is_valid.boolean = True # type: ignore 79 | 80 | def _context(self, obj: Visitor) -> str: 81 | return pretty_print(obj.context) 82 | 83 | _context.short_description = "Context (prettified)" # type: ignore 84 | 85 | 86 | @admin.register(VisitorLog) 87 | class VisitorLogAdmin(admin.ModelAdmin): 88 | list_display = ( 89 | "visitor", 90 | "session_key", 91 | "remote_addr", 92 | "request_uri", 93 | "status_code", 94 | "timestamp", 95 | ) 96 | readonly_fields = [f.name for f in VisitorLog._meta.fields] 97 | pass 98 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | import pytest 5 | from django.utils.timezone import now as tz_now 6 | 7 | from visitors.models import InvalidVisitorPass, Visitor 8 | 9 | TEST_UUID: str = "68201321-9dd2-4fb3-92b1-24367f38a7d6" 10 | 11 | TODAY: datetime.datetime = tz_now() 12 | ONE_DAY: datetime.timedelta = datetime.timedelta(days=1) 13 | TOMORROW: datetime.datetime = TODAY + ONE_DAY 14 | YESTERDAY: datetime.datetime = TODAY - ONE_DAY 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "url_in,url_out", 19 | ( 20 | ("google.com", f"google.com?vuid={TEST_UUID}"), 21 | ("google.com?vuid=123", f"google.com?vuid={TEST_UUID}"), 22 | ), 23 | ) 24 | def test_visitor_tokenise(url_in, url_out): 25 | visitor = Visitor(uuid=uuid.UUID(TEST_UUID)) 26 | assert visitor.tokenise(url_in) == url_out 27 | 28 | 29 | @pytest.mark.django_db 30 | def test_deactivate(): 31 | visitor = Visitor.objects.create(email="foo@bar.com") 32 | assert visitor.is_active 33 | visitor.deactivate() 34 | assert not visitor.is_active 35 | visitor.refresh_from_db() 36 | assert not visitor.is_active 37 | 38 | 39 | @pytest.mark.django_db 40 | def test_reactivate(): 41 | visitor = Visitor.objects.create( 42 | email="foo@bar.com", is_active=False, expires_at=YESTERDAY 43 | ) 44 | assert not visitor.is_active 45 | assert visitor.has_expired 46 | assert not visitor.is_valid 47 | visitor.reactivate() 48 | assert visitor.is_active 49 | assert not visitor.has_expired 50 | assert visitor.is_valid 51 | visitor.refresh_from_db() 52 | assert visitor.is_active 53 | assert not visitor.has_expired 54 | assert visitor.is_valid 55 | 56 | 57 | @pytest.mark.parametrize( 58 | "is_active,expires_at,is_valid", 59 | ( 60 | (True, TOMORROW, True), 61 | (False, TOMORROW, False), 62 | (False, YESTERDAY, False), 63 | (True, YESTERDAY, False), 64 | ), 65 | ) 66 | def test_validate(is_active, expires_at, is_valid): 67 | visitor = Visitor(is_active=is_active, expires_at=expires_at) 68 | assert visitor.is_active == is_active 69 | assert visitor.has_expired == bool(expires_at < TODAY) 70 | if is_valid: 71 | visitor.validate() 72 | return 73 | with pytest.raises(InvalidVisitorPass): 74 | visitor.validate() 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "is_active,expires_at,is_valid", 79 | ( 80 | (True, TOMORROW, True), 81 | (False, TOMORROW, False), 82 | (False, YESTERDAY, False), 83 | (True, YESTERDAY, False), 84 | (True, None, True), 85 | (False, None, False), 86 | ), 87 | ) 88 | def test_is_valid(is_active, expires_at, is_valid): 89 | visitor = Visitor(is_active=is_active, expires_at=expires_at) 90 | assert visitor.is_valid == is_valid 91 | 92 | 93 | def test_defaults(): 94 | visitor = Visitor() 95 | assert visitor.created_at 96 | assert visitor.expires_at == visitor.created_at + Visitor.DEFAULT_TOKEN_EXPIRY 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "expires_at,has_expired", 101 | ( 102 | (TOMORROW, False), 103 | (YESTERDAY, True), 104 | (None, False), 105 | ), 106 | ) 107 | def test_has_expired(expires_at, has_expired): 108 | visitor = Visitor() 109 | visitor.expires_at = expires_at 110 | assert visitor.has_expired == has_expired 111 | -------------------------------------------------------------------------------- /visitors/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Callable 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import MiddlewareNotUsed, ValidationError 8 | from django.http.request import HttpRequest 9 | from django.http.response import HttpResponse, HttpResponseBadRequest 10 | 11 | from . import session 12 | from .models import InvalidVisitorPass, Visitor 13 | from .settings import VISITOR_QUERYSTRING_KEY 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class VisitorRequestMiddleware: 19 | """Extract visitor token from incoming request.""" 20 | 21 | def __init__(self, get_response: Callable): 22 | self.get_response = get_response 23 | 24 | def __call__(self, request: HttpRequest) -> HttpResponse: 25 | request.visitor = None 26 | request.user.is_visitor = False 27 | visitor_uuid = request.GET.get(VISITOR_QUERYSTRING_KEY) 28 | if not visitor_uuid: 29 | return self.get_response(request) 30 | try: 31 | visitor = Visitor.objects.get(uuid=visitor_uuid) 32 | visitor.validate() 33 | except Visitor.DoesNotExist: 34 | logger.debug("Visitor pass does not exist: %s", visitor_uuid) 35 | return self.get_response(request) 36 | except InvalidVisitorPass as ex: 37 | logger.debug("Invalid access request: %s", ex) 38 | return self.get_response(request) 39 | except ValidationError as ex: 40 | logger.debug("Malformed visitor token: %s", ex) 41 | return HttpResponseBadRequest("Malformed visitor token.") 42 | else: 43 | request.visitor = visitor 44 | request.user.is_visitor = True 45 | return self.get_response(request) 46 | 47 | 48 | class VisitorSessionMiddleware: 49 | """Extract visitor info from session and update request user.""" 50 | 51 | def __init__(self, get_response: Callable): 52 | self.get_response = get_response 53 | 54 | def __call__(self, request: HttpRequest) -> HttpResponse: 55 | """ 56 | Update request.user if any visitor vars are found in session. 57 | 58 | This middleware should run after the VisitorRequestMiddleware. If 59 | a request comes in without a visitor token in the querystring, we 60 | need to check the request.session to see if any visitor data was 61 | stashed previously. 62 | 63 | The first time this middleware runs after a new session is created 64 | this will push the `request.visitor` info into the session. 65 | Subsequent requests will then get the data out of the session. 66 | 67 | """ 68 | # This will only be true directly after VisitorRequestMiddleware 69 | # has set the values. All subsequent requests in the session will 70 | # start with is_visitor=False and pick up the visitor info from 71 | # the session. 72 | if request.visitor: 73 | session.stash_visitor_uuid(request) 74 | return self.get_response(request) 75 | 76 | # We don't have a visitor object, but there may be one in the session 77 | if not (visitor_uuid := session.get_visitor_uuid(request)): 78 | return self.get_response(request) 79 | 80 | try: 81 | visitor = Visitor.objects.get( 82 | uuid=visitor_uuid, 83 | is_active=True, 84 | ) 85 | except Visitor.DoesNotExist: 86 | session.clear_visitor_uuid(request) 87 | return self.get_response(request) 88 | else: 89 | request.visitor = visitor 90 | request.user.is_visitor = True 91 | 92 | return self.get_response(request) 93 | 94 | 95 | class VisitorDebugMiddleware: 96 | """Print out visitor info - DEBUG only.""" 97 | 98 | def __init__(self, get_response: Callable): 99 | if not settings.DEBUG: 100 | raise MiddlewareNotUsed("VisitorDebugMiddleware disabled") 101 | self.get_response = get_response 102 | 103 | def __call__(self, request: HttpRequest) -> HttpResponse: 104 | logger.debug("request.user.is_visitor: %s", request.user.is_visitor) 105 | if request.user.is_visitor: 106 | logger.debug("request.visitor: %s", request.visitor) 107 | logger.debug( 108 | "request.visitor.session_expiry: %s", 109 | request.visitor.session_expiry, 110 | ) 111 | logger.debug( 112 | "request.session.get_expiry_date: %s", 113 | request.session.get_expiry_date(), 114 | ) 115 | return self.get_response(request) 116 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | from unittest import mock 5 | from uuid import uuid4 6 | 7 | import pytest 8 | from django.http import Http404 9 | from django.test import RequestFactory 10 | from django.urls import reverse 11 | from django.utils.timezone import now as tz_now 12 | 13 | from visitors.exceptions import InvalidVisitorPass 14 | from visitors.models import Visitor 15 | from visitors.views import SelfServiceRequest 16 | 17 | 18 | @pytest.mark.django_db 19 | class TestSelfService: 20 | @mock.patch("visitors.views.render") 21 | @pytest.mark.parametrize( 22 | "is_active,has_expired,error", 23 | [ 24 | (True, True, InvalidVisitorPass), 25 | (True, False, InvalidVisitorPass), 26 | (False, True, InvalidVisitorPass), 27 | (False, False, None), 28 | ], 29 | ) 30 | def test_get( 31 | self, 32 | mock_render, 33 | rf: RequestFactory, 34 | visitor: Visitor, 35 | is_active: bool, 36 | has_expired: bool, 37 | error: Exception, 38 | ) -> None: 39 | request = rf.get("/") 40 | request.visitor = visitor 41 | visitor.is_active = is_active 42 | if has_expired: 43 | visitor.expires_at = tz_now() - timedelta(seconds=1) 44 | visitor.save() 45 | view = SelfServiceRequest() 46 | assert visitor.has_expired == has_expired 47 | assert visitor.is_active == is_active 48 | if error: 49 | with pytest.raises(error): 50 | view.dispatch(request, visitor.uuid) 51 | else: 52 | resp = view.dispatch(request, visitor.uuid) 53 | assert resp.status_code == mock_render.return_value.status_code 54 | mock_render.assert_called_once_with( 55 | request, 56 | template_name="visitors/self_service_request.html", 57 | context={"visitor": visitor, "form": mock.ANY}, 58 | ) 59 | 60 | @mock.patch.object(SelfServiceRequest, "get_template_name") 61 | @mock.patch("visitors.views.render") 62 | def test_template_override( 63 | self, 64 | mock_render, 65 | mock_template_name, 66 | rf: RequestFactory, 67 | visitor: Visitor, 68 | ) -> None: 69 | request = rf.get("/") 70 | request.visitor = visitor 71 | visitor.is_active = False 72 | visitor.save() 73 | view = SelfServiceRequest(visitor=visitor) 74 | _ = view.dispatch(request, visitor.uuid) 75 | mock_template_name.assert_called_once_with() 76 | mock_render.assert_called_once_with( 77 | request, 78 | template_name=mock_template_name.return_value, 79 | context={"visitor": visitor, "form": mock.ANY}, 80 | ) 81 | 82 | def test_post_404(self, rf: RequestFactory) -> None: 83 | request = rf.post("/") 84 | view = SelfServiceRequest() 85 | with pytest.raises(Http404): 86 | view.dispatch(request, visitor_uuid=uuid4()) 87 | 88 | def test_post_valid(self, rf: RequestFactory, temp_visitor: Visitor) -> None: 89 | assert not temp_visitor.is_active 90 | request = rf.post( 91 | "/", 92 | { 93 | "vuid": temp_visitor.uuid, 94 | "first_name": "Henry", 95 | "last_name": "Root", 96 | "email": "henry@altavista.com", 97 | }, 98 | ) 99 | request.visitor = temp_visitor 100 | view = SelfServiceRequest() 101 | resp = view.dispatch(request, visitor_uuid=temp_visitor.uuid) 102 | assert resp.status_code == 302 103 | temp_visitor.refresh_from_db() 104 | assert temp_visitor.is_active 105 | assert temp_visitor.first_name == "Henry" 106 | assert temp_visitor.last_name == "Root" 107 | assert temp_visitor.email == "henry@altavista.com" 108 | assert resp.url == reverse( 109 | "visitors:self-service-success", 110 | kwargs={"visitor_uuid": temp_visitor.uuid}, 111 | ) 112 | 113 | def test_post_invalid(self, rf: RequestFactory, temp_visitor: Visitor) -> None: 114 | assert not temp_visitor.is_active 115 | request = rf.post( 116 | "/", 117 | { 118 | "vuid": temp_visitor.uuid, 119 | "first_name": "Henry", 120 | "last_name": "Root", 121 | "email": "henry", 122 | }, 123 | ) 124 | request.visitor = temp_visitor 125 | view = SelfServiceRequest() 126 | resp = view.dispatch(request, visitor_uuid=temp_visitor.uuid) 127 | assert resp.status_code == 200 128 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.contrib.auth.models import AnonymousUser, User 5 | from django.contrib.sessions.backends.base import SessionBase 6 | from django.core.exceptions import PermissionDenied 7 | from django.http import HttpRequest, HttpResponse 8 | from django.test import RequestFactory 9 | from django.urls import reverse 10 | 11 | from visitors.decorators import user_is_visitor 12 | from visitors.models import Visitor, VisitorLog 13 | 14 | 15 | @pytest.mark.django_db 16 | class TestDecorators: 17 | def _request( 18 | self, user: User | None = None, visitor: Visitor | None = None 19 | ) -> HttpRequest: 20 | factory = RequestFactory() 21 | request = factory.get("/") 22 | request.user = user or AnonymousUser() 23 | request.visitor = visitor 24 | request.user.is_visitor = visitor is not None 25 | request.session = SessionBase() 26 | return request 27 | 28 | def test_no_access(self) -> None: 29 | request = self._request() 30 | 31 | @user_is_visitor(scope="foo") 32 | def view(request: HttpRequest) -> HttpResponse: 33 | return HttpResponse("OK") 34 | 35 | with pytest.raises(PermissionDenied): 36 | _ = view(request) 37 | 38 | def test_incorrect_scope(self, visitor: Visitor) -> None: 39 | request = self._request(visitor=visitor) 40 | 41 | @user_is_visitor(scope="bar") 42 | def view(request: HttpRequest) -> HttpResponse: 43 | return HttpResponse("OK") 44 | 45 | with pytest.raises(PermissionDenied): 46 | _ = view(request) 47 | 48 | def test_correct_scope(self, visitor: Visitor) -> None: 49 | request = self._request(visitor=visitor) 50 | 51 | @user_is_visitor(scope="foo") 52 | def view(request: HttpRequest) -> HttpResponse: 53 | return HttpResponse("OK") 54 | 55 | response = view(request) 56 | assert response.status_code == 200 57 | assert response.content == b"OK" 58 | 59 | def test_any_scope(self, visitor: Visitor) -> None: 60 | request = self._request(visitor=visitor) 61 | 62 | @user_is_visitor(scope="*") 63 | def view(request: HttpRequest) -> HttpResponse: 64 | return HttpResponse("OK") 65 | 66 | response = view(request) 67 | assert response.status_code == 200 68 | assert response.content == b"OK" 69 | 70 | def test_bypass__True(self, user: User) -> None: 71 | """Check that the bypass param works.""" 72 | request = self._request(user=user) 73 | 74 | @user_is_visitor(scope="foo", bypass_func=lambda r: True) 75 | def view(request: HttpRequest) -> HttpResponse: 76 | return HttpResponse("OK") 77 | 78 | response = view(request) 79 | assert response.status_code == 200 80 | assert response.content == b"OK" 81 | 82 | def test_bypass__False(self, user: User) -> None: 83 | request = self._request(user=user) 84 | 85 | @user_is_visitor(scope="foo", bypass_func=lambda r: False) 86 | def view(request: HttpRequest) -> HttpResponse: 87 | return HttpResponse("OK") 88 | 89 | with pytest.raises(PermissionDenied): 90 | _ = view(request) 91 | 92 | def test_logging(self, visitor: Visitor) -> None: 93 | request = self._request(visitor=visitor) 94 | 95 | @user_is_visitor(scope="foo") 96 | def view(request: HttpRequest) -> HttpResponse: 97 | return HttpResponse("OK") 98 | 99 | response = view(request) 100 | log: VisitorLog = VisitorLog.objects.get() 101 | assert response.status_code == 200 102 | assert log.status_code == 200 103 | 104 | def test_logging__False(self, visitor: Visitor) -> None: 105 | request = self._request(visitor=visitor) 106 | 107 | @user_is_visitor(scope="foo", log_visit=False) 108 | def view(request: HttpRequest) -> HttpResponse: 109 | return HttpResponse("OK") 110 | 111 | _ = view(request) 112 | assert VisitorLog.objects.count() == 0 113 | 114 | def test_self_service_redirect(self): 115 | request = self._request(visitor=None) 116 | 117 | @user_is_visitor(scope="foo", self_service=True, self_service_session_expiry=66) 118 | def view(request: HttpRequest) -> HttpResponse: 119 | return HttpResponse("OK") 120 | 121 | response = view(request) 122 | assert response.status_code == 302 123 | visitor = Visitor.objects.get() 124 | assert visitor.is_self_service 125 | assert not visitor.is_active 126 | assert response.url == reverse( 127 | "visitors:self-service", kwargs={"visitor_uuid": visitor.uuid} 128 | ) 129 | assert visitor.session_expiry == 66 130 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Optional 3 | 4 | import pytest 5 | from django.contrib.auth.models import AnonymousUser, User 6 | from django.http.request import HttpRequest 7 | from django.test import RequestFactory 8 | 9 | from visitors.middleware import VisitorRequestMiddleware, VisitorSessionMiddleware 10 | from visitors.models import Visitor 11 | from visitors.settings import VISITOR_SESSION_KEY 12 | 13 | 14 | @pytest.fixture 15 | def visitor() -> Visitor: 16 | return Visitor.objects.create(email="fred@example.com", scope="foo") 17 | 18 | 19 | class Session(dict): 20 | """Fake Session model used to support `session_key` property.""" 21 | 22 | @property 23 | def session_key(self) -> str: 24 | return "foobar" 25 | 26 | def set_expiry(self, expiry: int) -> None: 27 | self.expiry = expiry 28 | 29 | 30 | class TestVisitorMiddlewareBase: 31 | def request(self, url: str, user: Optional[User] = None) -> HttpRequest: 32 | factory = RequestFactory() 33 | request = factory.get(url) 34 | request.user = user or AnonymousUser() 35 | request.session = Session() 36 | return request 37 | 38 | 39 | @pytest.mark.django_db 40 | class TestVisitorRequestMiddleware(TestVisitorMiddlewareBase): 41 | def test_no_token(self) -> None: 42 | request = self.request("/", AnonymousUser()) 43 | middleware = VisitorRequestMiddleware(lambda r: r) 44 | middleware(request) 45 | assert not request.user.is_visitor 46 | assert not request.visitor 47 | 48 | def test_token_does_not_exist(self) -> None: 49 | request = self.request(f"/?vuid={uuid.uuid4()}") 50 | middleware = VisitorRequestMiddleware(lambda r: r) 51 | middleware(request) 52 | assert not request.user.is_visitor 53 | assert not request.visitor 54 | 55 | def test_token_is_invalid(self, visitor: Visitor) -> None: 56 | visitor.deactivate() 57 | request = self.request(visitor.tokenise("/")) 58 | middleware = VisitorRequestMiddleware(lambda r: r) 59 | middleware(request) 60 | assert not request.user.is_visitor 61 | assert not request.visitor 62 | 63 | def test_token_validation_error(self, visitor: Visitor) -> None: 64 | request = self.request("/?vuid=123") 65 | middleware = VisitorRequestMiddleware(lambda r: r) 66 | resp = middleware(request) 67 | assert resp.status_code == 400 68 | assert not request.user.is_visitor 69 | assert not request.visitor 70 | 71 | def test_valid_token(self, visitor: Visitor) -> None: 72 | request = self.request(visitor.tokenise("/")) 73 | middleware = VisitorRequestMiddleware(lambda r: r) 74 | middleware(request) 75 | assert request.user.is_visitor 76 | assert request.visitor == visitor 77 | 78 | 79 | @pytest.mark.django_db 80 | class TestVisitorSessionMiddleware(TestVisitorMiddlewareBase): 81 | def request( 82 | self, 83 | url: str, 84 | user: Optional[User] = None, 85 | is_visitor: bool = False, 86 | visitor: Visitor = None, 87 | ) -> HttpRequest: 88 | request = super().request(url, user) 89 | request.user.is_visitor = is_visitor 90 | request.visitor = visitor 91 | return request 92 | 93 | def test_visitor(self, visitor: Visitor) -> None: 94 | """Check that request.visitor is stashed in session.""" 95 | request = self.request("/", is_visitor=True, visitor=visitor) 96 | assert not request.session.get(VISITOR_SESSION_KEY) 97 | middleware = VisitorSessionMiddleware(lambda r: r) 98 | middleware(request) 99 | assert request.session[VISITOR_SESSION_KEY] == visitor.session_data 100 | 101 | def test_no_visitor_no_session(self) -> None: 102 | """Check that no visitor on request or session passes.""" 103 | request = self.request("/", is_visitor=False, visitor=None) 104 | middleware = VisitorSessionMiddleware(lambda r: r) 105 | middleware(request) 106 | assert not request.user.is_visitor 107 | assert not request.visitor 108 | 109 | def test_visitor_in_session(self, visitor: Visitor) -> None: 110 | """Check no visitor on request, but in session.""" 111 | request = self.request("/", is_visitor=False, visitor=None) 112 | request.session[VISITOR_SESSION_KEY] = visitor.session_data 113 | middleware = VisitorSessionMiddleware(lambda r: r) 114 | middleware(request) 115 | assert request.user.is_visitor 116 | assert request.visitor == visitor 117 | 118 | def test_visitor_does_not_exist(self) -> None: 119 | """Check non-existant visitor in session.""" 120 | request = self.request("/", is_visitor=False, visitor=None) 121 | request.session[VISITOR_SESSION_KEY] = str(uuid.uuid4()) 122 | middleware = VisitorSessionMiddleware(lambda r: r) 123 | middleware(request) 124 | assert not request.user.is_visitor 125 | assert not request.visitor 126 | assert not request.session.get(VISITOR_SESSION_KEY) 127 | 128 | def test_visitor_session_expiry(self, visitor: Visitor) -> None: 129 | """Check that request.visitor.session_expiry is set from visitor.""" 130 | visitor.session_expiry = 327 # something memorable 131 | visitor.save() 132 | request = self.request("/", is_visitor=True, visitor=visitor) 133 | middleware = VisitorSessionMiddleware(lambda r: r) 134 | middleware(request) 135 | assert request.session.expiry == 327 136 | -------------------------------------------------------------------------------- /visitors/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import logging 5 | from typing import Any, Callable 6 | 7 | from django.conf import settings 8 | from django.http import HttpRequest, HttpResponse 9 | from django.http.response import HttpResponseRedirect 10 | from django.urls import reverse 11 | from django.utils.translation import gettext as _ 12 | 13 | from .exceptions import VisitorAccessDenied 14 | from .models import Visitor, VisitorLog 15 | from .settings import VISITOR_SESSION_EXPIRY 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | # universal scope - essentially unscoped access 20 | SCOPE_ANY = "*" 21 | 22 | # for typing 23 | BypassFunc = Callable[[HttpRequest], bool] 24 | 25 | 26 | def is_visitor(user: settings.AUTH_USER_MODEL) -> bool: 27 | """Shortcut function for use with user_passes_test decorator.""" 28 | return user.is_visitor 29 | 30 | 31 | def is_staff(user: settings.AUTH_USER_MODEL) -> bool: 32 | """Shortcut function for use with user_passes_test decorator.""" 33 | return user.is_staff 34 | 35 | 36 | def is_superuser(user: settings.AUTH_USER_MODEL) -> bool: 37 | """Shortcut function for use with user_passes_test decorator.""" 38 | return user.is_superuser 39 | 40 | 41 | def is_authenticated(user: settings.AUTH_USER_MODEL) -> bool: 42 | """Shortcut function for use with user_passes_test decorator.""" 43 | return user.is_authenticated 44 | 45 | 46 | def _get_request_arg(*args: Any) -> HttpRequest | None: 47 | """Extract the arg that is an HttpRequest object.""" 48 | for arg in args: 49 | if isinstance(arg, HttpRequest): 50 | return arg 51 | return None 52 | 53 | 54 | def user_is_visitor( # noqa: C901 55 | view_func: Callable | None = None, 56 | scope: str = "", 57 | bypass_func: BypassFunc | None = None, 58 | log_visit: bool = True, 59 | self_service: bool = False, 60 | self_service_session_expiry: int | None = VISITOR_SESSION_EXPIRY, 61 | ) -> Callable: 62 | """ 63 | Decorate view functions that supports Visitor access. 64 | 65 | The 'scope' param is mapped to the request.visitor.scope attribute - if 66 | the scope is SCOPE_ANY then this is ignored. 67 | 68 | The 'bypass_func' is a callable that can be used to provide exceptions 69 | to the scope - e.g. allowing authenticate users, or staff, to bypass the 70 | visitor restriction. Defaults to None (only visitors with appropriate 71 | scope allowed). 72 | 73 | The 'log_visit' arg can be used to override the default logging - if this 74 | is too noisy, for instance. 75 | 76 | If 'self_service' is True, then instead of a straight PermissionDenied error 77 | we raise VisitorAccessDenied, passing along the scope. This is then picked 78 | up in the middleware, and the user redirected to a page where they can 79 | enter their details and effectively invite themselves. Caveat emptor. 80 | 81 | """ 82 | if not scope: 83 | raise ValueError("Decorator scope cannot be empty.") 84 | 85 | if view_func is None: 86 | return functools.partial( 87 | user_is_visitor, 88 | scope=scope, 89 | bypass_func=bypass_func, 90 | log_visit=log_visit, 91 | self_service=self_service, 92 | self_service_session_expiry=self_service_session_expiry, 93 | ) 94 | 95 | @functools.wraps(view_func) 96 | def inner(*args: Any, **kwargs: Any) -> HttpResponse: 97 | # HACK: if this is decorating a method, then the first arg will be 98 | # the object (self), and not the request. In order to make this work 99 | # with functions and methods we need to determine where the request 100 | # arg is. 101 | request = _get_request_arg(*args) 102 | if not request: 103 | raise ValueError("Request argument missing.") 104 | 105 | # Allow custom rules to bypass the visitor checks 106 | if bypass_func and bypass_func(request): 107 | return view_func(*args, **kwargs) 108 | 109 | if not is_valid_request(request, scope): 110 | if self_service: 111 | return redirect_to_self_service( 112 | request, 113 | scope, 114 | self_service_session_expiry, 115 | ) 116 | raise VisitorAccessDenied(_("Visitor access denied"), scope) 117 | 118 | response = view_func(*args, **kwargs) 119 | if log_visit: 120 | VisitorLog.objects.create_log(request, response.status_code) 121 | return response 122 | 123 | return inner 124 | 125 | 126 | def is_valid_request(request: HttpRequest, scope: str) -> bool: 127 | """Return True if the request matches the scope.""" 128 | if not request.user.is_visitor: 129 | return False 130 | if scope == SCOPE_ANY: 131 | return True 132 | return request.visitor.scope == scope 133 | 134 | 135 | def redirect_to_self_service( 136 | request: HttpRequest, 137 | scope: str, 138 | session_expiry: int | None = VISITOR_SESSION_EXPIRY, 139 | ) -> HttpResponseRedirect: 140 | """Create inactive Visitor token and redirect to enable self-service.""" 141 | # create an inactive token for the time being. This will be used by 142 | # the auto-enroll view. The user fills in their name and email, which 143 | # overwrites the blank values here, and sets the token to be active. 144 | visitor = Visitor.objects.create_temp_visitor( 145 | scope=scope, 146 | redirect_to=request.get_full_path(), 147 | session_expiry=session_expiry, 148 | ) 149 | return HttpResponseRedirect( 150 | reverse( 151 | "visitors:self-service", 152 | kwargs={"visitor_uuid": visitor.uuid}, 153 | ) 154 | ) 155 | -------------------------------------------------------------------------------- /visitors/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from typing import Any 5 | 6 | from django import forms 7 | from django.http import Http404, HttpRequest, HttpResponse 8 | from django.http.response import HttpResponseRedirect 9 | from django.shortcuts import get_object_or_404, render 10 | from django.urls import reverse 11 | from django.views import View 12 | 13 | from visitors.exceptions import InvalidVisitorPass 14 | 15 | from .forms import SelfServiceForm 16 | from .models import Visitor 17 | from .signals import self_service_visitor_created 18 | 19 | 20 | class SelfServiceBase(View): 21 | """Base view for self-service pages.""" 22 | 23 | # default visitor request templates 24 | template_name = "" 25 | 26 | def __init__(self, **kwargs: Any) -> None: 27 | self.visitor: Visitor | None = None 28 | super().__init__(**kwargs) 29 | 30 | def get_template_name(self) -> str: 31 | """Return the path to the template to render.""" 32 | if self.template_name: 33 | return self.template_name 34 | raise NotImplementedError("Missing template_name property.") 35 | 36 | def get_context_data(self, **form_kwargs: object) -> dict: 37 | """ 38 | Return the context passed to the template. 39 | 40 | Default passes the Visitor object and anything that is passed in 41 | as form_kwargs. This is essentially a noop that exists only so 42 | that it can be overridden. 43 | 44 | """ 45 | context: dict = {"visitor": self.visitor} 46 | context.update(form_kwargs) 47 | return context 48 | 49 | def dispatch(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse: 50 | """Override default view dispatch to set visitor attr.""" 51 | self.visitor = get_object_or_404(Visitor, uuid=visitor_uuid) 52 | return super().dispatch(request, visitor_uuid) 53 | 54 | 55 | class SelfServiceRequest(SelfServiceBase): 56 | """ 57 | Enable users to create their own visitor passes. 58 | 59 | If a visitor pass is marked as `auto-enroll` then a user can 60 | create their own pass. In this model, an user without access 61 | would be redirected to this view, where they can input their 62 | own name and email. They receive their own visitor pass. 63 | 64 | The purpose of this is to add some protection to 'obscure' 65 | urls. e.g. those that rely on an unguessable uuid - instead 66 | of making those URLs completely public, we can force the user 67 | to give us their name / email (name can be faked, but they 68 | will require access to the email). 69 | 70 | """ 71 | 72 | # default visitor request templates 73 | template_name = "visitors/self_service_request.html" 74 | form_class = SelfServiceForm 75 | 76 | def get_form_class(self) -> forms.Form: 77 | """Return the SelfServiceForm used by the template.""" 78 | return self.form_class 79 | 80 | def get_form_initial(self) -> dict: 81 | """Return the initial data used for the form.""" 82 | if not self.visitor: # should never occur - set in dispatch() 83 | raise Http404 84 | return {"vuid": self.visitor.uuid} 85 | 86 | def get_redirect_url(self) -> str: 87 | """Return the post-POST success url.""" 88 | if not self.visitor: # should never occur - set in dispatch() 89 | raise Http404 90 | return reverse( 91 | "visitors:self-service-success", 92 | kwargs={"visitor_uuid": self.visitor.uuid}, 93 | ) 94 | 95 | def validate_visitor(self) -> Visitor: 96 | """Validate the local Visitor object.""" 97 | if not self.visitor: # should never occur - set in dispatch() 98 | raise Http404 99 | if self.visitor.is_active: 100 | raise InvalidVisitorPass("Visitor pass has already been activated") 101 | if self.visitor.has_expired: 102 | raise InvalidVisitorPass("Visitor pass has expired") 103 | return self.visitor 104 | 105 | def get(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse: 106 | """Render the initial form.""" 107 | _ = self.validate_visitor() 108 | template = self.get_template_name() 109 | form = self.get_form_class()(initial=self.get_form_initial()) 110 | context = self.get_context_data(form=form) 111 | return render(request, template_name=template, context=context) 112 | 113 | def post(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse: 114 | """ 115 | Process the form and send the pass email. 116 | 117 | The core function here is to update the visitor object with the 118 | details from the form. Once that is done the visitor pass is 119 | active and can be sent to the user. This view fires the 120 | `self_service_visitor_created` signal - you should use this to 121 | send out the notification. 122 | 123 | """ 124 | visitor = self.validate_visitor() 125 | form = self.get_form_class()(request.POST) 126 | if form.is_valid(): 127 | visitor.first_name = form.cleaned_data["first_name"] 128 | visitor.last_name = form.cleaned_data["last_name"] 129 | visitor.email = form.cleaned_data["email"] 130 | visitor.reactivate() 131 | # hook into this to send the email notification to the user. 132 | self_service_visitor_created.send(sender=self.__class__, visitor=visitor) 133 | return HttpResponseRedirect(self.get_redirect_url()) 134 | template = self.get_template_name() 135 | context = self.get_context_data(form=form) 136 | return render(request, template_name=template, context=context) 137 | 138 | 139 | class SelfServiceSuccess(SelfServiceBase): 140 | """Render the page that appears after a successful self-service request.""" 141 | 142 | template_name = "visitors/self_service_success.html" 143 | 144 | def get(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse: 145 | return render( 146 | request, 147 | template_name=self.get_template_name(), 148 | context=self.get_context_data(), 149 | ) 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Visitor Pass 2 | 3 | Django app for managing temporary session-based users. 4 | 5 | ### Support 6 | 7 | This project currently supports Python 3.10+, Django 4.2+. 8 | 9 | ### Background 10 | 11 | This package has been extracted out of `django-request-token` as a specific use 12 | case - that of temporary site "visitors". It enables a type of ephemeral user 13 | who is neither anonymous nor authenticated, but somewhere in between - known for 14 | the duration of their session. 15 | 16 | ### Motivation 17 | 18 | We've been using `django-request-token` for a while, and have issued over 19 | 100,000 tokens. A recent analysis showed two main use cases - single-use "magic 20 | links" for logging people in, and a more involved case where we invite 21 | unregistered users on to the platform to perform some action - providing a 22 | reference perhaps, or collaborating on something with (registered) users. The 23 | former we have extracted out into `django-magic-links` - and this package 24 | addresses the latter. 25 | 26 | ### What is a "visitor"? 27 | 28 | In the standard Django model you have the concept of an `AnonymousUser`, and an 29 | authenticated `User` - someone who has logged in. We have a third, intermediate, 30 | type of user - which we have historically referred to as a "Temp User", which is 31 | someone we know _of_, but who has not yet registered. 32 | 33 | The canonical example of this is leaving a reference: user A on the site invites 34 | user B to leave a reference for them. They (A) give us B's name and email, we 35 | invite them to click on a link and fill out a form. That's it. We store their 36 | name and email so that we can contact them, but it's ephemeral - we don't need 37 | it, and we don't use it. Storing this data in the User table made sense (as it 38 | has name and email fields), but it led to a lot of `user_type=TEMP` munging to 39 | determine who is a 'real' user on the site. 40 | 41 | What we really want is to 'stash' this information somewhere outside of the auth 42 | system, and to enable these temp users to have restricted access to specific 43 | areas of the application, for a limited period, after which we can forget about 44 | them and clear out the data. 45 | 46 | We call these users "visitors". 47 | 48 | ### Use Case - request a reference 49 | 50 | Fred is a registered user on the site, and would like a reference from Ginger, 51 | his dance partner. 52 | 53 | 1. Fred fills out the reference request form: 54 | 55 | ``` 56 | Name: Ginger 57 | Email: ginger@[...].com 58 | Message: ... 59 | Scope: REFERENCE_REQUEST [hidden field] 60 | ``` 61 | 62 | 2. We save this information, and generate a unique link which we send to Ginger, 63 | along with the message. 64 | 65 | 3. Ginger clicks on the link, at which point we recognise that this is someone 66 | we know about - a "visitor" - but who is in all other respects an 67 | `AnonymousUser`. 68 | 69 | 4. We stash the visitor info in the standard session object - so that even 70 | though Ginger is not authenticated, we know who she is, and more importantly 71 | we know why she's here (REFERENCE_REQUEST). 72 | 73 | 5. Ginger submits the reference - which may be a multi-step process, involving 74 | GETs and POSTs, all of which are guarded by a decorator that restricts access 75 | to visitors with the appropriate Scope (just like `django-request-token`). 76 | 77 | 6. At the final step we can (optionally) choose to clear the session info 78 | immediately, effectively removing all further access. 79 | 80 | ### Implementation 81 | 82 | This code has been extracted out of `django-request-token` and simplified. It 83 | stores the visitor data in the `Visitor` model, and on each successful first 84 | request (where the token is 'processed' and the session filled) we record a 85 | `VisitorLog` record. This includes HTTP request info (session_key, referer, 86 | client IP, user-agent). This information is for analytics only - for instance 87 | determining whether links are being shared. 88 | 89 | The app works by adding some attributes to the `request` and `request.user` 90 | objects. The user has a boolean `user.is_visitor` property, and the request has 91 | a `request.visitor` property which is the relevant `Visitor` object. 92 | 93 | This is done via two bits of middleware, `VisitorRequestMiddleware` and 94 | `VisitSessionMiddleware`. 95 | 96 | #### `VisitorRequestMiddleware` 97 | 98 | This middleware looks for a visitor token (uuid) on the incoming request 99 | querystring. If it finds a token, it will look up the `Visitor` object, add it 100 | to the request, and then set the `request.user.is_visitor` attribute. It sets 101 | the properties from the request, and has no interaction with the session. This 102 | happens in the second piece of middleware. 103 | 104 | #### `VisitorSessionMiddleware` 105 | 106 | This middleware must come after the `VisitorRequestMiddleware` (it will blow up 107 | if it can't access `request.visitor`). It has two responsibilities: 108 | 109 | 1. If the request object has a visitor object on it, then it _must_ have been 110 | set by the request middleware on the current request - so it's a new visitor, 111 | and we immediately stash it in the `request.session`. 112 | 113 | 1. If `request.visitor` is None, then we don't have a _new_ visitor, but there 114 | may be one already stashed in the `request.session`, in which case we want to 115 | add it on the to the request. 116 | 117 | Note: splitting this in two seems over-complicated, but because we are moving 118 | values from request-into-session-into-request it's a lot simpler to run two 119 | completely separate passes. 120 | 121 | ### Configuration 122 | 123 | #### Django Settings 124 | 125 | 1. Add `visitors` to `INSTALLED_APPS` 126 | 1. Add `visitors.middleware.VisitorRequestMiddleware` to `MIDDLEWARE` 127 | 1. Add `visitors.middleware.VisitorSessionMiddleware` to `MIDDLEWARE` 128 | 129 | #### Environment Settings 130 | 131 | * `VISITOR_SESSION_KEY`: session key used to stash visitor info (default: 132 | `visitor:session`) 133 | 134 | * `VISITOR_SESSION_EXPIRY`: session expiry in seconds (default: 0 - meaning 135 | that it will expire when the browser is closed.) This settings applies 136 | as the default for all new visitor objects, but can be overridden on a 137 | per-object basis. 138 | 139 | * `VISITOR_QUERYSTRING_KEY`: querystring param used on tokenised links (default: 140 | `vuid`) 141 | 142 | ### Usage 143 | 144 | Once you have the package configured, you can use the `user_is_visitor` 145 | decorator to protect views that you want to restricted to visitors only. The 146 | decorator requires a `scope` kwarg, which must match the `Visitor.scope` value 147 | set when the `Visitor` object is created. 148 | 149 | ```python 150 | @user_is_visitor(scope="foo") 151 | def protected_view(request): 152 | pass 153 | ``` 154 | 155 | By default the decorator will allow visitors with the correct scope only. If you 156 | want more control over the access, you can pass a callable as the `bypass_func` 157 | param: 158 | 159 | ```python 160 | # allow authenticated users as well as visitors 161 | @user_is_visitor(scope="foo", bypass_func=lambda r: r.user.is_staff) 162 | def allow_visitors_and_staff(request): 163 | pass 164 | ``` 165 | 166 | If you don't care about the scope (you should), you can use `"*"` to allow all 167 | visitors access: 168 | 169 | ```python 170 | @user_is_visitor(scope="*") # see also SCOPE_ANY 171 | def allow_all_scopes(request): 172 | pass 173 | ``` 174 | 175 | Alternatively, for more complex use cases, you can ignore the decorator and just 176 | inspect the request itself: 177 | 178 | ```python 179 | def complicated_rules(request): 180 | if request.user.is_visitor: 181 | pass 182 | elif is_national_holiday(): 183 | pass 184 | elif is_sunny_day(): 185 | pass 186 | else: 187 | raise PermissionDenied 188 | ``` 189 | -------------------------------------------------------------------------------- /visitors/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import uuid 5 | from typing import Any 6 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 7 | 8 | from django.db import models 9 | from django.db.models.deletion import CASCADE 10 | from django.http.request import HttpRequest 11 | from django.utils.timezone import now as tz_now 12 | from django.utils.translation import gettext_lazy as _lazy 13 | 14 | from .exceptions import InvalidVisitorPass 15 | from .settings import ( 16 | VISITOR_QUERYSTRING_KEY, 17 | VISITOR_SESSION_EXPIRY, 18 | VISITOR_TOKEN_EXPIRY, 19 | ) 20 | 21 | 22 | class VisitorManager(models.Manager): 23 | def create_temp_visitor( 24 | self, 25 | scope: str, 26 | redirect_to: str, 27 | session_expiry: int | None = VISITOR_SESSION_EXPIRY, 28 | ) -> Visitor: 29 | """Create empty Visitor object for self-service.""" 30 | return self.create( 31 | email=Visitor.DEFAULT_SELF_SERVICE_EMAIL, 32 | scope=scope, 33 | is_active=False, 34 | context={"self-service": True, "redirect_to": redirect_to}, 35 | session_expiry=session_expiry, 36 | ) 37 | 38 | 39 | class Visitor(models.Model): 40 | """A temporary visitor (betwixt anonymous and authenticated).""" 41 | 42 | DEFAULT_TOKEN_EXPIRY = datetime.timedelta(seconds=VISITOR_TOKEN_EXPIRY) 43 | DEFAULT_SELF_SERVICE_EMAIL = "anon@example.com" 44 | 45 | uuid = models.UUIDField(default=uuid.uuid4, unique=True) 46 | first_name = models.CharField(max_length=150, blank=True) 47 | last_name = models.CharField(max_length=150, blank=True) 48 | email = models.EmailField(db_index=True) 49 | scope = models.CharField( 50 | max_length=100, help_text=_lazy("Used to map request to view function") 51 | ) 52 | created_at = models.DateTimeField(default=tz_now) 53 | context = models.JSONField( 54 | null=True, 55 | blank=True, 56 | help_text=_lazy("Used to store arbitrary contextual data."), 57 | ) 58 | last_updated_at = models.DateTimeField(auto_now=True) 59 | expires_at = models.DateTimeField( 60 | blank=True, 61 | null=True, 62 | help_text=_lazy( 63 | "After this time the link can no longer be used - " 64 | "defaults to VISITOR_TOKEN_EXPIRY." 65 | ), 66 | ) 67 | is_active = models.BooleanField( 68 | default=True, 69 | help_text=_lazy( 70 | "Set to False to disable the visitor link and prevent further access." 71 | ), 72 | ) 73 | session_expiry = models.PositiveIntegerField( 74 | default=VISITOR_SESSION_EXPIRY, 75 | help_text=_lazy("Time in seconds after which visitor session should expire."), 76 | ) 77 | 78 | objects = VisitorManager() 79 | 80 | class Meta: 81 | verbose_name = "Visitor pass" 82 | verbose_name_plural = "Visitor passes" 83 | 84 | def __str__(self) -> str: 85 | return f"Visitor pass {self.id} (scope='{self.scope}')" 86 | 87 | def __repr__(self) -> str: 88 | return ( 89 | f"" 91 | ) 92 | 93 | def __init__(self, *args: Any, **kwargs: Any): 94 | super().__init__(*args, **kwargs) 95 | if not self.expires_at: 96 | self.expires_at = self.created_at + self.DEFAULT_TOKEN_EXPIRY 97 | 98 | @property 99 | def full_name(self) -> str: 100 | return f"{self.first_name} {self.last_name}" 101 | 102 | @property 103 | def session_data(self) -> str: 104 | return str(self.uuid) 105 | 106 | @property 107 | def has_expired(self) -> bool: 108 | """Return True if the expires_at timestamp has been passed (or not yet set).""" 109 | if not self.expires_at: 110 | return False 111 | return self.expires_at < tz_now() 112 | 113 | @property 114 | def is_valid(self) -> bool: 115 | """Return True if the token is active and not yet expired.""" 116 | return self.is_active and not self.has_expired 117 | 118 | def is_self_service(self) -> bool: 119 | """Return True if the token was a self-service token.""" 120 | return self.context.get("self-service", False) 121 | 122 | def validate(self) -> None: 123 | """Raise InvalidVisitorPass if inactive or expired.""" 124 | if not self.is_active: 125 | raise InvalidVisitorPass("Visitor pass is inactive") 126 | if self.has_expired: 127 | raise InvalidVisitorPass("Visitor pass has expired") 128 | 129 | def serialize(self) -> dict: 130 | """ 131 | Return JSON-serializable representation. 132 | 133 | Useful for template context and session data. 134 | 135 | """ 136 | return { 137 | "uuid": str(self.uuid), 138 | "first_name": self.first_name, 139 | "last_name": self.last_name, 140 | "full_name": self.full_name, 141 | "email": self.email, 142 | "scope": self.scope, 143 | "context": self.context, 144 | } 145 | 146 | def tokenise(self, url: str) -> str: 147 | """Combine url with querystring token.""" 148 | # from https://stackoverflow.com/a/2506477/45698 149 | parts = list(urlparse(url)) 150 | query = parse_qs(parts[4]) 151 | query.update({VISITOR_QUERYSTRING_KEY: self.uuid}) 152 | parts[4] = urlencode(query) 153 | return urlunparse(parts) 154 | 155 | def deactivate(self) -> None: 156 | """Deactivate the token so it can no longer be used.""" 157 | self.is_active = False 158 | self.save() 159 | 160 | def reactivate(self) -> None: 161 | """Reactivate the token so it can be reused.""" 162 | self.is_active = True 163 | self.expires_at = tz_now() + self.DEFAULT_TOKEN_EXPIRY 164 | self.save() 165 | 166 | 167 | class VisitorLogManager(models.Manager): 168 | def create_log(self, request: HttpRequest, status_code: int) -> VisitorLog: 169 | """Extract values from HttpRequest and store locally.""" 170 | return self.create( 171 | visitor=request.visitor, 172 | session_key=request.session.session_key or "", 173 | http_method=request.method, 174 | request_uri=request.path, 175 | query_string=request.META.get("QUERY_STRING", ""), 176 | http_user_agent=request.META.get("HTTP_USER_AGENT", ""), 177 | # we care about the domain more than the URL itself, so truncating 178 | # doesn't lose much useful information 179 | http_referer=request.META.get("HTTP_REFERER", ""), 180 | # X-Forwarded-For is used by convention when passing through 181 | # load balancers etc., as the REMOTE_ADDR is rewritten in transit 182 | remote_addr=( 183 | request.META.get("HTTP_X_FORWARDED_FOR") 184 | if "HTTP_X_FORWARDED_FOR" in request.META 185 | else request.META.get("REMOTE_ADDR") 186 | ), 187 | status_code=status_code, 188 | ) 189 | 190 | 191 | class VisitorLog(models.Model): 192 | """Log visitors.""" 193 | 194 | visitor = models.ForeignKey(Visitor, related_name="visits", on_delete=CASCADE) 195 | session_key = models.CharField(blank=True, max_length=40) 196 | http_method = models.CharField(max_length=10) 197 | request_uri = models.URLField() 198 | remote_addr = models.CharField(max_length=100) 199 | query_string = models.TextField(blank=True) 200 | http_user_agent = models.TextField() 201 | http_referer = models.TextField() 202 | status_code = models.PositiveIntegerField("HTTP Response", default=0) 203 | timestamp = models.DateTimeField(default=tz_now) 204 | 205 | objects = VisitorLogManager() 206 | --------------------------------------------------------------------------------