├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── hype ├── __init__.py ├── admin.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── urls.py └── views.py ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_admin.py ├── test_middleware.py ├── test_views.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | poetry.lock 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | version: ~> 1.0 2 | 3 | os: linux 4 | language: python 5 | 6 | python: "3.9" 7 | 8 | stages: 9 | - name: test 10 | - name: deploy 11 | if: tag IS present 12 | 13 | jobs: 14 | fast_finish: true 15 | include: 16 | - stage: test 17 | name: "Flake8" 18 | install: 19 | - pip install --upgrade pip setuptools wheel 20 | - pip install tox 21 | env: 22 | - TOXENV=flake8 23 | script: 24 | - tox 25 | 26 | - stage: test 27 | name: "pytest" 28 | language: python 29 | python: "3.9" 30 | install: 31 | - pip install tox 32 | script: tox -e py39 33 | cache: pip 34 | 35 | - stage: deploy 36 | language: generic 37 | python: "3.9" 38 | install: 39 | - pip install --user awscli 40 | script: 41 | - python ./setup.py sdist 42 | - DISTFILE=`find dist/ -type f` 43 | - aws s3 cp $DISTFILE s3://$S3_DEFAULT_BUCKET/ 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Justin Mayer 2022 – present 2 | Copyright (c) Jerome Leclanche 2017 – 2022 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hype 2 | 3 | A Django module that implements referral link logic. 4 | 5 | 6 | ## Concepts 7 | 8 | 9 | This library implements two models: 10 | 11 | - The `ReferralLink`. This object represents a user's referral link, or invitation link. 12 | It has a string identifier which allows the user to share their link as `/ref//`. 13 | - The `ReferralHit`. This is an instance of a user (logged in or anonymous) actually following 14 | a referral link. 15 | 16 | ## The Anonymous Cookie 17 | 18 | When a ReferralLink is followed, a `ReferralHit` object is created. If the link was followed 19 | by a logged in user, that user will be available on the ReferralHit object as a foreign key. 20 | If the link was followed by an anonymous user, a cookie will be set on the user for future 21 | reference. 22 | 23 | The cookie contains a random UUID which is set on the ReferralHit. At any time, you may get 24 | that cookie and, should the user log in, update all ReferralHit objects with that matching 25 | UUID. 26 | The library includes a middleware which will automatically do this for every logged in users, 27 | see `hype.middleware.AnonymousReferralMiddleware`. 28 | 29 | 30 | ## Confirming Referrals 31 | 32 | You may wish to implement a `SuccessfulReferral` model which is created when a user who 33 | previously followed a `ReferralLink` (and thus created a `ReferralHit`) actually completes 34 | whichever steps the referral system requires referred users to complete (for example: 35 | Register to the website, make their first purchase, post their first comment, ...). 36 | 37 | The `ReferralHit` model also has a `confirmed` DateTimeField which you may use for this purpose. 38 | 39 | 40 | ## Supporting Referral Links on Any URL 41 | 42 | Implementers may find it useful to allow a referral on any URL. This is implemented in the 43 | `hype.middleware.ReferralLinkMiddleware` middleware, which looks at all GET requests 44 | and, should a valid referral link be present in the GET parameters, redirects to that referral 45 | link's URL with the `next` parameter set to the original URL, without the referral link present. 46 | 47 | Example: 48 | - `/accounts/signup/?ref=abc123def` redirects to... 49 | - `/ref/abs123def?next=/accounts/signup/` which redirects to... 50 | - `/accounts/signup/`, after creating a ReferralHit. 51 | 52 | 53 | ## Setup and configuration 54 | 55 | 1. Install via `python -m pip install django-hype` 56 | 2. Add `hype` to your `INSTALLED_APPS` 57 | 3. Include `hype.urls` in your URLs. Example: `url(r"^ref/", include("hype.urls"))` 58 | 4. Add `hype.middleware.AnonymousReferralMiddleware` to your `MIDDLEWARE`. 59 | This is required to update referrals for anonymous users when they log in. 60 | 5. (optional) Add `hype.middleware.ReferralLinkMiddleware` to your `MIDDLEWARE`. 61 | This is required if you want `?ref=...` to redirect properly. 62 | 63 | These steps are enough to start gathering referral information. 64 | You create a referral link, and watch the `ReferralHit` table fill up as users follow it. 65 | 66 | In addition to having that data, you may want to "confirm" referrals. The `ConfirmedReferral` 67 | model is there as a convenience model to allow you to filter down the referral hits in question. 68 | Upon creating a ConfirmedReferral you may also want to do something else, such as crediting a 69 | user some points. 70 | The atomicity and idempotency of such events is left as an exercise for the reader. 71 | 72 | 73 | ## License 74 | 75 | This project is licensed under the MIT license. The full license text is 76 | available in the LICENSE file. 77 | -------------------------------------------------------------------------------- /hype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justinmayer/django-hype/72857cec113889e0aef56de7d0443106c5c0f890/hype/__init__.py -------------------------------------------------------------------------------- /hype/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | 6 | @admin.register(models.ReferralLink) 7 | class ReferralLinkAdmin(admin.ModelAdmin): 8 | list_display = ("identifier", "user", "disabled", "created", "updated") 9 | list_filter = ("disabled",) 10 | raw_id_fields = ("user",) 11 | search_fields = ("user__username", "identifier") 12 | 13 | 14 | @admin.register(models.ReferralHit) 15 | class ReferralHitAdmin(admin.ModelAdmin): 16 | list_display = ( 17 | "id", 18 | "referral_link", 19 | "next", 20 | "hit_user", 21 | "authenticated", 22 | "ip", 23 | "created", 24 | "updated", 25 | ) 26 | list_filter = ("authenticated",) 27 | raw_id_fields = ("referral_link", "hit_user") 28 | search_fields = ("id", "referral_link__identifier") 29 | 30 | def has_add_permission(self, request): 31 | return False 32 | -------------------------------------------------------------------------------- /hype/middleware.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | from uuid import UUID 3 | 4 | from django.shortcuts import redirect 5 | 6 | from .models import ReferralHit, ReferralLink 7 | from .settings import COOKIE_KEY, URL_PARAM 8 | 9 | 10 | class AnonymousReferralMiddleware: 11 | def __init__(self, get_response): 12 | self.get_response = get_response 13 | 14 | def __call__(self, request): 15 | response = self.get_response(request) 16 | 17 | if request.user and request.user.is_authenticated: 18 | if COOKIE_KEY in request.COOKIES: 19 | value = request.COOKIES[COOKIE_KEY] 20 | 21 | try: 22 | value = UUID(value) 23 | except ValueError: 24 | # A bad ID was stored in the cookie (non-uuid). Harmless. 25 | pass 26 | else: 27 | ReferralHit.objects.filter(pk=value, hit_user=None).update( 28 | hit_user=request.user 29 | ) 30 | 31 | response.delete_cookie(COOKIE_KEY) 32 | 33 | return response 34 | 35 | 36 | class ReferralLinkMiddleware: 37 | def __init__(self, get_response): 38 | self.get_response = get_response 39 | 40 | def __call__(self, request): 41 | if request.method == "GET" and URL_PARAM in request.GET: 42 | ref_id = request.GET[URL_PARAM] 43 | try: 44 | ref_link = ReferralLink.objects.get(identifier=ref_id) 45 | except ReferralLink.DoesNotExist: 46 | return self.get_response(request) 47 | 48 | params = request.GET.copy() 49 | del params[URL_PARAM] 50 | orig_path = request.path 51 | if params: 52 | orig_path += "?" + urlencode(params) 53 | 54 | final_path = ( 55 | ref_link.get_absolute_url() + "?" + urlencode({"next": orig_path}) 56 | ) 57 | 58 | response = redirect(final_path) 59 | else: 60 | response = self.get_response(request) 61 | 62 | return response 63 | -------------------------------------------------------------------------------- /hype/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.5 on 2017-11-25 17:46 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="ReferralHit", 19 | fields=[ 20 | ( 21 | "id", 22 | models.UUIDField( 23 | default=uuid.uuid4, 24 | editable=False, 25 | primary_key=True, 26 | serialize=False, 27 | ), 28 | ), 29 | ( 30 | "authenticated", 31 | models.BooleanField( 32 | help_text="Whether the hit was created by an authenticated user." 33 | ), 34 | ), 35 | ( 36 | "ip", 37 | models.GenericIPAddressField(help_text="IP address at hit time"), 38 | ), 39 | ( 40 | "user_agent", 41 | models.TextField(blank=True, help_text="User-Agent at hit time"), 42 | ), 43 | ( 44 | "http_referer", 45 | models.TextField( 46 | blank=True, help_text="Referrer header at hit time" 47 | ), 48 | ), 49 | ( 50 | "next", 51 | models.URLField( 52 | blank=True, 53 | help_text="The ?next parameter when the link was hit.", 54 | ), 55 | ), 56 | ( 57 | "confirmed", 58 | models.DateTimeField( 59 | db_index=True, 60 | help_text="If set, the datetime at which the hit was marked as a successful referral.", 61 | null=True, 62 | blank=True, 63 | ), 64 | ), 65 | ("created", models.DateTimeField(auto_now_add=True)), 66 | ("updated", models.DateTimeField(auto_now=True)), 67 | ( 68 | "hit_user", 69 | models.ForeignKey( 70 | null=True, 71 | blank=True, 72 | on_delete=django.db.models.deletion.SET_NULL, 73 | to=settings.AUTH_USER_MODEL, 74 | ), 75 | ), 76 | ], 77 | ), 78 | migrations.CreateModel( 79 | name="ReferralLink", 80 | fields=[ 81 | ( 82 | "id", 83 | models.AutoField( 84 | primary_key=True, serialize=False, verbose_name="ID" 85 | ), 86 | ), 87 | ( 88 | "identifier", 89 | models.CharField(blank=True, max_length=50, unique=True), 90 | ), 91 | ("disabled", models.BooleanField(default=False)), 92 | ("created", models.DateTimeField(auto_now_add=True)), 93 | ("updated", models.DateTimeField(auto_now=True)), 94 | ( 95 | "user", 96 | models.ForeignKey( 97 | null=True, 98 | on_delete=django.db.models.deletion.SET_NULL, 99 | to=settings.AUTH_USER_MODEL, 100 | ), 101 | ), 102 | ], 103 | ), 104 | migrations.AddField( 105 | model_name="referralhit", 106 | name="referral_link", 107 | field=models.ForeignKey( 108 | on_delete=django.db.models.deletion.CASCADE, to="hype.ReferralLink" 109 | ), 110 | ), 111 | ] 112 | -------------------------------------------------------------------------------- /hype/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justinmayer/django-hype/72857cec113889e0aef56de7d0443106c5c0f890/hype/migrations/__init__.py -------------------------------------------------------------------------------- /hype/models.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.urls import reverse 6 | 7 | 8 | class ReferralHitManager(models.Manager): 9 | pass 10 | 11 | 12 | class ReferralLink(models.Model): 13 | id = models.AutoField(primary_key=True, serialize=False, verbose_name="ID") 14 | identifier = models.CharField(max_length=50, blank=True, unique=True) 15 | user = models.ForeignKey( 16 | settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True 17 | ) 18 | disabled = models.BooleanField(default=False) 19 | 20 | created = models.DateTimeField(auto_now_add=True) 21 | updated = models.DateTimeField(auto_now=True) 22 | 23 | def __str__(self): 24 | return self.get_absolute_url() 25 | 26 | def get_absolute_url(self): 27 | return reverse("hype_reflink", kwargs={"identifier": self.identifier}) 28 | 29 | 30 | class ReferralHit(models.Model): 31 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 32 | referral_link = models.ForeignKey(ReferralLink, on_delete=models.CASCADE) 33 | authenticated = models.BooleanField( 34 | help_text="Whether the hit was created by an authenticated user." 35 | ) 36 | hit_user = models.ForeignKey( 37 | settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True 38 | ) 39 | ip = models.GenericIPAddressField(help_text="IP address at hit time") 40 | user_agent = models.TextField(blank=True, help_text="User-Agent at hit time") 41 | http_referer = models.TextField(blank=True, help_text="Referer header at hit time") 42 | next = models.URLField( 43 | blank=True, help_text="The ?next parameter when the link was hit." 44 | ) 45 | confirmed = models.DateTimeField( 46 | null=True, 47 | blank=True, 48 | db_index=True, 49 | help_text="If set, the datetime at which the hit was marked as a successful referral.", 50 | ) 51 | 52 | created = models.DateTimeField(auto_now_add=True) 53 | updated = models.DateTimeField(auto_now=True) 54 | 55 | def __str__(self): 56 | return f"{self.hit_user or '(anonymous user)'} -> {self.referral_link}" 57 | -------------------------------------------------------------------------------- /hype/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | DJANGO_REFERRALS_SETTINGS = { 5 | "COOKIE_KEY": "django-hype__rk", 6 | "COOKIE_HTTPONLY": True, 7 | "COOKIE_MAX_AGE": 60 * 60 * 24 * 365, 8 | "URL_PARAM": "ref", 9 | } 10 | 11 | 12 | DJANGO_REFERRALS_SETTINGS.update(getattr(settings, "DJANGO_REFERRALS_SETTINGS", {})) 13 | 14 | 15 | COOKIE_KEY = DJANGO_REFERRALS_SETTINGS["COOKIE_KEY"] 16 | COOKIE_HTTPONLY = DJANGO_REFERRALS_SETTINGS["COOKIE_HTTPONLY"] 17 | COOKIE_MAX_AGE = DJANGO_REFERRALS_SETTINGS["COOKIE_MAX_AGE"] 18 | URL_PARAM = DJANGO_REFERRALS_SETTINGS["URL_PARAM"] 19 | -------------------------------------------------------------------------------- /hype/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from .views import ReferralView 4 | 5 | 6 | # app_name = "hype" 7 | 8 | urlpatterns = [ 9 | re_path(r"^(?P\w+)$", ReferralView.as_view(), name="hype_reflink"), 10 | ] 11 | -------------------------------------------------------------------------------- /hype/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from django.utils.http import url_has_allowed_host_and_scheme 3 | from django.views import View 4 | 5 | from .models import ReferralHit, ReferralLink 6 | from .settings import COOKIE_HTTPONLY, COOKIE_KEY, COOKIE_MAX_AGE 7 | 8 | 9 | class ReferralView(View): 10 | success_url = "/" 11 | 12 | def get(self, request, identifier): 13 | self.next = self.request.GET.get("next", "") 14 | self.cookie_value = None 15 | 16 | try: 17 | ref_link = ReferralLink.objects.get(identifier=identifier) 18 | except ReferralLink.DoesNotExist: 19 | return self.fail("broken_referral") 20 | 21 | if ref_link.disabled: 22 | return self.fail("referral_disabled") 23 | 24 | return self.success(ref_link) 25 | 26 | def get_success_url(self): 27 | if self.next and url_has_allowed_host_and_scheme(self.next, allowed_hosts=None): 28 | return self.next 29 | return self.success_url 30 | 31 | def success(self, ref_link): 32 | self.hit(ref_link) 33 | return self.redirect(self.get_success_url()) 34 | 35 | def fail(self, code): 36 | return self.redirect(self.get_success_url()) 37 | 38 | def redirect(self, url): 39 | response = redirect(url) 40 | 41 | if self.cookie_value: 42 | response.set_cookie( 43 | COOKIE_KEY, 44 | self.cookie_value, 45 | max_age=COOKIE_MAX_AGE, 46 | httponly=COOKIE_HTTPONLY, 47 | ) 48 | else: 49 | response.delete_cookie(COOKIE_KEY) 50 | 51 | return response 52 | 53 | def hit(self, ref_link): 54 | user = self.request.user if self.request.user.is_authenticated else None 55 | hit = ReferralHit.objects.create( 56 | referral_link=ref_link, 57 | hit_user=user, 58 | authenticated=user is not None, 59 | ip=self.request.META["REMOTE_ADDR"], 60 | user_agent=self.request.META.get("HTTP_USER_AGENT", ""), 61 | http_referer=self.request.META.get("HTTP_REFERER", ""), 62 | next=self.next, 63 | ) 64 | 65 | if user is None: 66 | self.cookie_value = str(hit.pk) 67 | 68 | return hit 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-hype" 3 | version = "1.0.0" 4 | description = "Referral links for Django" 5 | authors = ["Justin Mayer "] 6 | license = "MIT" 7 | readme = "README.md" 8 | keywords = ["django", "referral", "links"] 9 | packages = [ 10 | { include = "hype" }, 11 | ] 12 | 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Framework :: Django", 16 | "Operating System :: OS Independent", 17 | ] 18 | 19 | [tool.poetry.urls] 20 | "Funding" = "https://github.com/sponsors/justinmayer" 21 | "Issue Tracker" = "https://github.com/justinmayer/django-hype/issues" 22 | 23 | [tool.poetry.dependencies] 24 | python = ">=3.7,<4.0" 25 | Django = ">=3.2" 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | # Testing 29 | psutil = ">=5.9" 30 | pytest = "~7.1" 31 | pytest-cov = ">=3.0" 32 | pytest-django = ">=4.5" 33 | pytest-icdiff = ">=0.5" 34 | pytest-randomly = ">=3.11" 35 | pytest-sugar = ">=0.9" 36 | pytest-xdist = ">=2.5" 37 | 38 | # Linting 39 | black = "^22" 40 | isort = ">=5.10" 41 | 42 | [tool.pytest.ini_options] 43 | DJANGO_SETTINGS_MODULE = "tests.settings" 44 | 45 | [build-system] 46 | requires = ["poetry-core"] 47 | build-backend = "poetry.core.masonry.api" 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justinmayer/django-hype/72857cec113889e0aef56de7d0443106c5c0f890/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hype.models import ReferralLink 4 | 5 | 6 | @pytest.fixture(scope="function") 7 | def ref_link(): 8 | yield ReferralLink.objects.create(identifier="Example123") 9 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "hunter2" 2 | DEBUG = True 3 | SITE_ID = 1 4 | USE_TZ = True 5 | 6 | DATABASES = { 7 | "default": { 8 | "ENGINE": "django.db.backends.sqlite3", 9 | "NAME": ":memory:", 10 | }, 11 | } 12 | 13 | INSTALLED_APPS = [ 14 | "django.contrib.admin", 15 | "django.contrib.auth", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "hype", 19 | ] 20 | 21 | TEMPLATES = [ 22 | { 23 | "BACKEND": "django.template.backends.django.DjangoTemplates", 24 | "DIRS": [], 25 | "APP_DIRS": True, 26 | "OPTIONS": { 27 | "context_processors": [ 28 | "django.template.context_processors.debug", 29 | "django.template.context_processors.request", 30 | "django.contrib.auth.context_processors.auth", 31 | "django.contrib.messages.context_processors.messages", 32 | ], 33 | }, 34 | } 35 | ] 36 | 37 | MIDDLEWARE = [ 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.contrib.auth.middleware.AuthenticationMiddleware", 41 | "hype.middleware.AnonymousReferralMiddleware", 42 | "hype.middleware.ReferralLinkMiddleware", 43 | ] 44 | 45 | ROOT_URLCONF = "tests.urls" 46 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | def test_load_admin(admin_client): 2 | response = admin_client.get("/admin/hype/") 3 | assert response.status_code == 200 4 | 5 | response = admin_client.get("/admin/hype/referrallink/") 6 | assert response.status_code == 200 7 | 8 | response = admin_client.get("/admin/hype/referralhit/") 9 | assert response.status_code == 200 10 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hype import settings 4 | from hype.models import ReferralHit 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_anonymous_middleware(client, admin_user, ref_link): 9 | ref_link_url = ref_link.get_absolute_url() 10 | 11 | response = client.get(ref_link_url, HTTP_USER_AGENT="TestAgent") 12 | assert response.status_code == 302 13 | assert response.url == "/" 14 | 15 | assert ReferralHit.objects.count() == 1 16 | hit = ReferralHit.objects.latest("created") 17 | assert not hit.authenticated 18 | assert not hit.hit_user 19 | 20 | cookie = response.cookies[settings.COOKIE_KEY] 21 | assert cookie.value == str(hit.pk) 22 | 23 | client.force_login(admin_user) 24 | response = client.get("/foo") 25 | assert ReferralHit.objects.count() == 1 26 | assert response.status_code == 404 27 | 28 | # Cookie should have been deleted 29 | assert response.cookies[settings.COOKIE_KEY].value == "" 30 | 31 | # And old request should have been updated 32 | hit = ReferralHit.objects.get(pk=hit.pk) 33 | assert hit.hit_user == admin_user 34 | 35 | 36 | def test_anonymous_middleware_bad_cookie(admin_client, ref_link): 37 | admin_client.cookies.load({settings.COOKIE_KEY: "looks nothing like a uuid"}) 38 | response = admin_client.get("/foo") 39 | assert response.status_code == 404 40 | 41 | admin_client.cookies.load( 42 | {settings.COOKIE_KEY: "00000000-0000-0000-0000-000000000000"} 43 | ) 44 | response = admin_client.get("/foo") 45 | assert response.status_code == 404 46 | 47 | 48 | def test_referral_link_middleware(admin_client, ref_link): 49 | ref_link_url = ref_link.get_absolute_url() 50 | 51 | response = admin_client.get("/foo?ref=" + ref_link.identifier) 52 | assert response.status_code == 302 53 | assert response.url == ref_link_url + "?next=%2Ffoo" 54 | 55 | response = admin_client.post("/foo?ref=" + ref_link.identifier) 56 | assert response.status_code == 404 57 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hype import settings 4 | from hype.models import ReferralHit 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_referral_logged_in(admin_client, ref_link): 9 | ref_link_url = ref_link.get_absolute_url() 10 | client = admin_client 11 | 12 | ref = "https://example.com" 13 | response = client.get(ref_link_url, HTTP_USER_AGENT="TestAgent", HTTP_REFERER=ref) 14 | assert response.status_code == 302 15 | assert response.url == "/" 16 | assert response.cookies[settings.COOKIE_KEY].value == "" 17 | 18 | assert ReferralHit.objects.count() == 1 19 | hit = ReferralHit.objects.latest("created") 20 | assert hit.authenticated 21 | assert hit.hit_user == response.wsgi_request.user 22 | assert hit.ip == "127.0.0.1" 23 | assert hit.user_agent == response.wsgi_request.META["HTTP_USER_AGENT"] 24 | assert hit.http_referer == ref 25 | assert not hit.next 26 | assert not hit.confirmed 27 | 28 | next = "/foo" 29 | response = client.get(ref_link_url + "?next=" + next) 30 | assert response.status_code == 302 31 | assert response.url == next 32 | 33 | assert ReferralHit.objects.count() == 2 34 | hit = ReferralHit.objects.latest("created") 35 | assert hit.authenticated 36 | assert hit.hit_user == response.wsgi_request.user 37 | assert hit.next == next 38 | assert not hit.confirmed 39 | assert response.cookies[settings.COOKIE_KEY].value == "" 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_referral_logged_out(client, ref_link): 44 | ref_link_url = ref_link.get_absolute_url() 45 | 46 | response = client.get(ref_link_url, HTTP_USER_AGENT="TestAgent") 47 | assert response.status_code == 302 48 | assert response.url == "/" 49 | 50 | assert ReferralHit.objects.count() == 1 51 | hit = ReferralHit.objects.latest("created") 52 | assert not hit.authenticated 53 | assert not hit.hit_user 54 | assert hit.ip == "127.0.0.1" 55 | assert hit.user_agent == response.wsgi_request.META["HTTP_USER_AGENT"] 56 | assert not hit.next 57 | assert not hit.confirmed 58 | 59 | cookie = response.cookies[settings.COOKIE_KEY] 60 | assert cookie.value == str(hit.pk) 61 | 62 | next = "/foo" 63 | response = client.get(ref_link_url + "?next=" + next) 64 | assert response.status_code == 302 65 | assert response.url == next 66 | 67 | assert ReferralHit.objects.count() == 2 68 | hit = ReferralHit.objects.latest("created") 69 | assert not hit.authenticated 70 | assert not hit.hit_user 71 | assert hit.next == next 72 | assert not hit.confirmed 73 | 74 | cookie = response.cookies[settings.COOKIE_KEY] 75 | assert cookie.value == str(hit.pk) 76 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, re_path 3 | 4 | 5 | urlpatterns = [ 6 | re_path(r"^admin/", admin.site.urls), 7 | re_path(r"^ref/", include("hype.urls")), 8 | ] 9 | --------------------------------------------------------------------------------