diff --git a/.env.example b/.env.example index 4a75fc14..49fb5b71 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ DEBUG=True ALLOWED_HOSTS=localhost +APEX_DOMAIN=redirectioneaza.ro SECRET_KEY="replace-this-example-key" SENTRY_DSN="" diff --git a/backend/partners/__init__.py b/backend/partners/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/partners/admin.py b/backend/partners/admin.py new file mode 100644 index 00000000..1be2a212 --- /dev/null +++ b/backend/partners/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Partner + + +@admin.register(Partner) +class PartnerAdmin(admin.ModelAdmin): + pass diff --git a/backend/partners/apps.py b/backend/partners/apps.py new file mode 100644 index 00000000..7196dcc0 --- /dev/null +++ b/backend/partners/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PartnersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "partners" diff --git a/backend/partners/middleware.py b/backend/partners/middleware.py new file mode 100644 index 00000000..0cdf1927 --- /dev/null +++ b/backend/partners/middleware.py @@ -0,0 +1,66 @@ +from django.conf import settings +from django.http import Http404 + +from .models import Partner + + +class InvalidSubdomain(Exception): + pass + + +class PartnerDomainMiddleware: + """ + Add the `request.partner` property based on the requested subdomain + """ + + @staticmethod + def extract_subdomain(host: str, apex: str) -> str: + apex = apex.strip().lower() + dot_apex = "." + apex + host = host.strip().lower() + + # Drop the port number (if present) + host = host.split(":", maxsplit=1)[0] + + # Drop the www. prefix (if present) + if host[:4] == "www.": + host = host[4:] + + # Extract the subdomain name + if host == apex: + subdomain = "" + elif host.endswith(dot_apex): + subdomain = host.split(dot_apex, maxsplit=1)[0] + else: + raise InvalidSubdomain + + return subdomain + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + subdomain = PartnerDomainMiddleware.extract_subdomain(request.get_host(), settings.APEX_DOMAIN) + except InvalidSubdomain: + raise Http404 + + if not subdomain: + partner = None + else: + try: + partner = Partner.objects.get(subdomain=subdomain) + except Partner.DoesNotExist: + partner = None + + request.partner = partner + + # Code to be executed for each request before + # the view (and later middleware) are called. + + response = self.get_response(request) + + # Code to be executed for each request/response after + # the view is called. + + return response diff --git a/backend/partners/migrations/0001_initial.py b/backend/partners/migrations/0001_initial.py new file mode 100644 index 00000000..8185e55a --- /dev/null +++ b/backend/partners/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.8 on 2023-12-27 14:48 + +from django.db import migrations, models +import django.db.models.functions.text + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("donations", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Partner", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(blank=True, db_index=True, max_length=100, verbose_name="name")), + ("subdomain", models.CharField(max_length=100, unique=True, verbose_name="subdomain")), + ("is_active", models.BooleanField(db_index=True, default=True, verbose_name="is active")), + ("ngos", models.ManyToManyField(to="donations.ngo", verbose_name="NGOs")), + ], + options={ + "verbose_name": "Partner", + "verbose_name_plural": "Partners", + }, + ), + migrations.AddConstraint( + model_name="partner", + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower("subdomain"), name="subdomain_unique" + ), + ), + ] diff --git a/backend/partners/migrations/0002_alter_partner_ngos.py b/backend/partners/migrations/0002_alter_partner_ngos.py new file mode 100644 index 00000000..28a0fddc --- /dev/null +++ b/backend/partners/migrations/0002_alter_partner_ngos.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.8 on 2023-12-27 15:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("donations", "0001_initial"), + ("partners", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="partner", + name="ngos", + field=models.ManyToManyField(blank=True, to="donations.ngo", verbose_name="NGOs"), + ), + ] diff --git a/backend/partners/migrations/__init__.py b/backend/partners/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/partners/models.py b/backend/partners/models.py new file mode 100644 index 00000000..c26c674d --- /dev/null +++ b/backend/partners/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.db.models.functions import Lower +from django.utils.translation import gettext_lazy as _ + +from donations.models import Ngo + + +class Partner(models.Model): + name = models.CharField(verbose_name=_("name"), max_length=100, blank=True, null=False, db_index=True) + subdomain = models.CharField(verbose_name=_("subdomain"), max_length=100, blank=False, null=False, unique=True) + is_active = models.BooleanField(verbose_name=_("is active"), db_index=True, default=True) + ngos = models.ManyToManyField(verbose_name=_("NGOs"), to=Ngo, blank=True) + + class Meta: + verbose_name = _("Partner") + verbose_name_plural = _("Partners") + constraints = [ + models.UniqueConstraint(Lower("subdomain"), name="subdomain_unique"), + ] + + def __str__(self): + return f"{self.name}" diff --git a/backend/partners/tests.py b/backend/partners/tests.py new file mode 100644 index 00000000..93a5cb5e --- /dev/null +++ b/backend/partners/tests.py @@ -0,0 +1,24 @@ +from django.test import TestCase + +from .middleware import PartnerDomainMiddleware, InvalidSubdomain + + +class PartnerDomainMiddlewareTestCase(TestCase): + def setUp(self): + self.apex = "example.com" + + def test_subdomain(self): + # Test standard subdomain extraction + subdom1 = PartnerDomainMiddleware.extract_subdomain("test1.example.com", self.apex) + self.assertEqual(subdom1, "test1") + + # Test various capitalization subdomain extraction + subdom2 = PartnerDomainMiddleware.extract_subdomain("TesT1.exaMpLe.com", self.apex) + self.assertEqual(subdom2, "test1") + + # Test apex domain + subdom3 = PartnerDomainMiddleware.extract_subdomain("exAmple.com", self.apex) + self.assertEqual(subdom3, "") + + # Test invalid subdomain + self.assertRaises(InvalidSubdomain, PartnerDomainMiddleware.extract_subdomain, "test1.example.ORG", self.apex) diff --git a/backend/partners/views.py b/backend/partners/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/backend/partners/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/backend/redirectioneaza/settings.py b/backend/redirectioneaza/settings.py index 320b395b..0e923690 100644 --- a/backend/redirectioneaza/settings.py +++ b/backend/redirectioneaza/settings.py @@ -42,6 +42,7 @@ DATABASE_HOST=(str, "localhost"), DATABASE_PORT=(str, "3306"), # site settings + APEX_DOMAIN=(str, "redirectioneaza.ro"), SITE_TITLE=(str, "redirectioneaza 3,5%"), DONATIONS_LIMIT_DATE=(str, "2016-05-25"), DONATIONS_LIMIT_TO_CURRENT_YEAR=(bool, True), @@ -94,9 +95,8 @@ DJANGO_ADMIN_PASSWORD = env.str("DJANGO_ADMIN_PASSWORD", None) DJANGO_ADMIN_EMAIL = env.str("DJANGO_ADMIN_EMAIL", None) -# Security settings - ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") +APEX_DOMAIN = env.str("APEX_DOMAIN") CSRF_HEADER_NAME = "HTTP_X_XSRF_TOKEN" CSRF_COOKIE_NAME = "XSRF-TOKEN" @@ -150,6 +150,7 @@ "django_q", # custom apps: "donations", + "partners", "users", ] @@ -161,6 +162,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "partners.middleware.PartnerDomainMiddleware", ] ROOT_URLCONF = "redirectioneaza.urls"