django-multisite2 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. django_multisite2-2.0.0.dist-info/LICENSE +28 -0
  2. django_multisite2-2.0.0.dist-info/METADATA +246 -0
  3. django_multisite2-2.0.0.dist-info/RECORD +51 -0
  4. django_multisite2-2.0.0.dist-info/WHEEL +5 -0
  5. django_multisite2-2.0.0.dist-info/top_level.txt +2 -0
  6. multisite/__init__.py +1 -0
  7. multisite/admin/__init__.py +3 -0
  8. multisite/admin/alias_admin.py +58 -0
  9. multisite/admin/multisite_changelist.py +64 -0
  10. multisite/admin/multisite_model_admin.py +161 -0
  11. multisite/apps.py +29 -0
  12. multisite/exceptions.py +57 -0
  13. multisite/forms.py +22 -0
  14. multisite/hacks.py +163 -0
  15. multisite/management/__init__.py +0 -0
  16. multisite/management/commands/__init__.py +0 -0
  17. multisite/management/commands/update_public_suffix_list.py +33 -0
  18. multisite/middleware/__init__.py +4 -0
  19. multisite/middleware/cookie_domain_middleware.py +72 -0
  20. multisite/middleware/dynamic_site_middleware.py +198 -0
  21. multisite/migrations/0001_initial.py +71 -0
  22. multisite/migrations/0002_alter_alias_id_alter_alias_is_canonical.py +32 -0
  23. multisite/migrations/0003_alter_alias_options_alter_alias_unique_together_and_more.py +41 -0
  24. multisite/migrations/0004_alter_alias_managers.py +22 -0
  25. multisite/migrations/__init__.py +0 -0
  26. multisite/models/__init__.py +3 -0
  27. multisite/models/alias.py +110 -0
  28. multisite/models/managers.py +196 -0
  29. multisite/models/signals.py +36 -0
  30. multisite/models/validators.py +14 -0
  31. multisite/tests/__init__.py +0 -0
  32. multisite/tests/get_test_allowed_hosts.py +14 -0
  33. multisite/tests/get_test_http_response.py +13 -0
  34. multisite/tests/hosts.py +46 -0
  35. multisite/tests/test_settings.py +23 -0
  36. multisite/tests/tests/__init__.py +0 -0
  37. multisite/tests/tests/request_factory.py +12 -0
  38. multisite/tests/tests/test_alias.py +271 -0
  39. multisite/tests/tests/test_contrib_site.py +21 -0
  40. multisite/tests/tests/test_cookie_domain_middleware.py +196 -0
  41. multisite/tests/tests/test_dynamic_site_middleware.py +262 -0
  42. multisite/tests/tests/test_site_cache.py +127 -0
  43. multisite/tests/tests/test_site_domain.py +24 -0
  44. multisite/tests/tests/test_site_id.py +76 -0
  45. multisite/tests/tests/test_template_loader.py +49 -0
  46. multisite/threadlocals.py +136 -0
  47. multisite/utils.py +172 -0
  48. multisite_app/__init__.py +0 -0
  49. multisite_app/apps.py +6 -0
  50. multisite_app/urls.py +5 -0
  51. multisite_app/views.py +8 -0
@@ -0,0 +1,41 @@
1
+ # Generated by Django 5.0.3 on 2024-03-22 03:52
2
+
3
+ import multisite.models
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("multisite", "0002_alter_alias_id_alter_alias_is_canonical"),
11
+ ("sites", "0002_alter_domain_unique"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AlterModelOptions(
16
+ name="alias",
17
+ options={"verbose_name": "Alias", "verbose_name_plural": "Aliases"},
18
+ ),
19
+ migrations.AlterUniqueTogether(
20
+ name="alias",
21
+ unique_together=set(),
22
+ ),
23
+ migrations.AlterField(
24
+ model_name="alias",
25
+ name="is_canonical",
26
+ field=models.IntegerField(
27
+ default=None,
28
+ editable=False,
29
+ help_text="Does this domain name match the one in site?",
30
+ null=True,
31
+ validators=[multisite.models.validate_1_or_none],
32
+ verbose_name="is canonical?",
33
+ ),
34
+ ),
35
+ migrations.AddConstraint(
36
+ model_name="alias",
37
+ constraint=models.UniqueConstraint(
38
+ fields=("is_canonical", "site"), name="unique_is_canonical_site"
39
+ ),
40
+ ),
41
+ ]
@@ -0,0 +1,22 @@
1
+ # Generated by Django 4.2.11 on 2024-03-27 04:14
2
+
3
+ from django.db import migrations
4
+ import multisite.models.managers
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ("multisite", "0003_alter_alias_options_alter_alias_unique_together_and_more"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterModelManagers(
15
+ name="alias",
16
+ managers=[
17
+ ("objects", multisite.models.managers.AliasManager()),
18
+ ("canonical", multisite.models.managers.CanonicalAliasManager()),
19
+ ("aliases", multisite.models.managers.NotCanonicalAliasManager()),
20
+ ],
21
+ ),
22
+ ]
File without changes
@@ -0,0 +1,3 @@
1
+ from .alias import Alias
2
+ from .signals import post_save_site_created, pre_save_site_domain_changed
3
+ from .validators import validate_1_or_none, validate_true_or_none
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from django.contrib.sites.models import Site
4
+ from django.core.exceptions import ValidationError
5
+ from django.db import models
6
+ from django.db.models import UniqueConstraint
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ from .managers import AliasManager, CanonicalAliasManager, NotCanonicalAliasManager
10
+ from .validators import validate_1_or_none
11
+
12
+ _site_domain = Site._meta.get_field("domain")
13
+
14
+
15
+ __all__ = ["Alias"]
16
+
17
+
18
+ class Alias(models.Model):
19
+ """
20
+ Model for domain-name aliases for Site objects.
21
+
22
+ Domain names must be unique in the format of: 'hostname[:port].'
23
+ Each Site object that has a domain must have an ``is_canonical``
24
+ Alias.
25
+ """
26
+
27
+ domain = type(_site_domain)(
28
+ _("domain name"),
29
+ max_length=_site_domain.max_length,
30
+ unique=True,
31
+ help_text=_('Either "domain" or "domain:port"'),
32
+ )
33
+
34
+ site = models.ForeignKey(Site, related_name="aliases", on_delete=models.CASCADE)
35
+
36
+ is_canonical = models.IntegerField(
37
+ _("is canonical?"),
38
+ default=None,
39
+ null=True,
40
+ editable=False,
41
+ validators=[validate_1_or_none],
42
+ help_text=_("Does this domain name match the one in site?"),
43
+ )
44
+
45
+ redirect_to_canonical = models.BooleanField(
46
+ _("redirect to canonical?"),
47
+ default=True,
48
+ help_text=_("Should this domain name redirect to the one in site?"),
49
+ )
50
+
51
+ def __str__(self):
52
+ return f"{self.domain} -> {self.site.domain}"
53
+
54
+ def __repr__(self):
55
+ return f"<Alias: {self}>"
56
+
57
+ objects = AliasManager()
58
+ canonical = CanonicalAliasManager()
59
+ aliases = NotCanonicalAliasManager()
60
+
61
+ class Meta:
62
+ verbose_name = _("Alias")
63
+ verbose_name_plural = _("Aliases")
64
+ constraints = [
65
+ UniqueConstraint(
66
+ name="unique_is_canonical_site",
67
+ fields=["is_canonical", "site"],
68
+ # nulls_distinct=False, DJ5
69
+ )
70
+ ]
71
+
72
+ def save_base(self, *args, **kwargs):
73
+ """For canonical Alias, domains must match Site domains.
74
+
75
+ This needs to be validated here so that it is executed *after* the
76
+ Site pre-save signal updates the domain (an AliasInline modelform
77
+ on SiteAdmin will be saved (and it's clean methods run before
78
+ the Site is saved)
79
+ """
80
+ self.full_clean()
81
+ if self.is_canonical and self.domain != self.site.domain:
82
+ raise ValidationError({"domain": ["Does not match %r" % self.site]})
83
+ super().save_base(*args, **kwargs)
84
+
85
+ def validate_unique(self, exclude=None) -> None:
86
+ errors = {}
87
+ try:
88
+ super().validate_unique(exclude=exclude)
89
+ except ValidationError as e:
90
+ errors = e.update_error_dict(errors)
91
+ if exclude is not None and "domain" not in exclude:
92
+ errors = self._validate_domain_is_unique(errors)
93
+ if errors:
94
+ raise ValidationError(errors)
95
+
96
+ def _validate_domain_is_unique(self, errors: dict) -> dict:
97
+ """Ensure domain is unique, insensitive to case"""
98
+ field_name = "domain"
99
+ field_error = self.unique_error_message(self.__class__, (field_name,))
100
+ if field_name not in errors or str(field_error) not in [
101
+ str(err) for err in errors[field_name]
102
+ ]:
103
+ queryset = self.__class__.objects.filter(
104
+ **{field_name + "__iexact": getattr(self, field_name)}
105
+ )
106
+ if self.pk is not None:
107
+ queryset = queryset.exclude(pk=self.pk)
108
+ if queryset.exists():
109
+ errors.setdefault(field_name, []).append(field_error)
110
+ return errors
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import operator
4
+ from functools import reduce
5
+ from typing import TYPE_CHECKING
6
+
7
+ from django.contrib.sites import managers
8
+ from django.core.exceptions import FieldDoesNotExist, ValidationError
9
+ from django.core.validators import validate_ipv4_address
10
+ from django.db import models
11
+ from django.db.models import Q
12
+ from django.db.models.constants import LOOKUP_SEP
13
+
14
+ from ..exceptions import MultisiteInvalidHostError
15
+
16
+ if TYPE_CHECKING:
17
+ from django.db.models import QuerySet
18
+
19
+ from .alias import Alias
20
+
21
+ __all__ = [
22
+ "AliasManager",
23
+ "CanonicalAliasManager",
24
+ "NotCanonicalAliasManager",
25
+ "SpanningCurrentSiteManager",
26
+ ]
27
+
28
+
29
+ class AliasManager(models.Manager):
30
+ """Manager for all Aliases."""
31
+
32
+ use_in_migrations = True
33
+
34
+ def get_queryset(self) -> QuerySet[Alias]:
35
+ return super().get_queryset().select_related("site")
36
+
37
+ def resolve(self, host: str, port: str | None = None) -> list[str] | None:
38
+ """Returns the Alias that best matches ``host`` and
39
+ ``port``, or None.
40
+
41
+ ``host`` is a hostname like ``'example.com'``.
42
+ ``port`` is a port number like 8000, or None.
43
+
44
+ Attempts to first match by 'host:port' against
45
+ Alias.domain. If that fails, it will try to match the bare
46
+ 'host' with no port number.
47
+
48
+ All comparisons are done case-insensitively.
49
+ """
50
+ domains = self._expand_netloc(host=host, port=port)
51
+ q: Q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains))
52
+ aliases = dict((a.domain, a) for a in self.get_queryset().filter(q))
53
+ for domain in domains:
54
+ try:
55
+ return aliases[domain]
56
+ except KeyError:
57
+ pass
58
+ return None
59
+
60
+ @classmethod
61
+ def _expand_netloc(cls, host: str, port: str | None = None) -> list[str]:
62
+ """Returns a list of possible domain expansions for ``host``
63
+ and ``port``.
64
+
65
+ ``host`` is a hostname like ``'example.com'``.
66
+ ``port`` is a port number like 8000, or None.
67
+
68
+ Expansions are ordered from highest to lowest preference and may
69
+ include wildcards. Examples::
70
+
71
+ >>> AliasManager._expand_netloc('www.example.com')
72
+ ['www.example.com', '*.example.com', '*.com', '*']
73
+
74
+ >>> AliasManager._expand_netloc('www.example.com', 80)
75
+ ['www.example.com:80', 'www.example.com',
76
+ '*.example.com:80', '*.example.com',
77
+ '*.com:80', '*.com',
78
+ '*:80', '*']
79
+ """
80
+ if not host:
81
+ raise MultisiteInvalidHostError("Invalid host: %s" % host)
82
+ try:
83
+ validate_ipv4_address(host)
84
+ except ValidationError:
85
+ # Not an IP address
86
+ bits = host.split(".")
87
+ else:
88
+ bits = [host]
89
+ result = []
90
+ for i in range(0, (len(bits) + 1)):
91
+ if i == 0:
92
+ host = ".".join(bits[i:])
93
+ else:
94
+ host = ".".join(["*"] + bits[i:])
95
+ if port:
96
+ result.append("%s:%s" % (host, port))
97
+ result.append(host)
98
+ return result
99
+
100
+
101
+ class CanonicalAliasManager(models.Manager):
102
+ """Manager for Alias objects where is_canonical == 1."""
103
+
104
+ use_in_migrations = True
105
+
106
+ def get_queryset(self) -> QuerySet[Alias]:
107
+ queryset = super().get_queryset()
108
+ return queryset.filter(is_canonical=1)
109
+
110
+
111
+ class NotCanonicalAliasManager(models.Manager):
112
+ """Manager for Aliases where is_canonical != 1."""
113
+
114
+ use_in_migrations = True
115
+
116
+ def get_queryset(self) -> QuerySet[Alias]:
117
+ queryset = super().get_queryset()
118
+ return queryset.exclude(is_canonical=1)
119
+
120
+
121
+ class SpanningCurrentSiteManager(managers.CurrentSiteManager):
122
+ """Unlike ``django.contrib.sites.managers.CurrentSiteManager``,
123
+ this CurrentSiteManager can span multiple related models by using
124
+ the django filtering syntax, namely foo__bar__baz__site.
125
+
126
+ For example, let's say you have a model called Layer, which has
127
+ a field called family, which points to a model called LayerFamily,
128
+ which in turn has a field called site pointing to a
129
+ django.contrib.sites Site model. On Layer, add the following
130
+ manager:
131
+
132
+ on_site = SpanningCurrentSiteManager("family__site")
133
+
134
+ and it will do the proper thing.
135
+ """
136
+
137
+ def _validate_field_name(self):
138
+ """Given the field identifier, goes down the chain to check that
139
+ each specified field
140
+ a) exists,
141
+ b) is of type ForeignKey or ManyToManyField
142
+
143
+ If no field name is specified when instantiating
144
+ SpanningCurrentSiteManager, it tries to find either 'site' or
145
+ 'sites' as the site link, much like CurrentSiteManager does.
146
+ """
147
+ if self._CurrentSiteManager__field_name is None:
148
+ # Guess at field name
149
+ field_names = self.model._meta.get_all_field_names()
150
+ for potential_name in ["site", "sites"]:
151
+ if potential_name in field_names:
152
+ self._CurrentSiteManager__field_name = potential_name
153
+ break
154
+ else:
155
+ raise ValueError(
156
+ f"{self.__class__.__name__} couldn't find a field named either 'site' or 'sites' "
157
+ f"in {self.model._meta.object_name}."
158
+ )
159
+
160
+ field_name_chain = self._CurrentSiteManager__field_name.split(LOOKUP_SEP)
161
+ model = self.model
162
+
163
+ for field_name in field_name_chain:
164
+ # Throws an exception if anything goes bad
165
+ self.validate_single_field_name(model, field_name)
166
+ model = self.get_related_model(model, field_name)
167
+
168
+ # If we get this far without an exception, everything is good
169
+ self._CurrentSiteManager__is_validated = True
170
+
171
+ @staticmethod
172
+ def validate_single_field_name(model, field_name) -> None:
173
+ """Checks if the given field_name can be used to make a link
174
+ between a model and a site.
175
+
176
+ If anything is wrong, will raise an appropriate exception,
177
+ because that is what CurrentSiteManager expects.
178
+ """
179
+ try:
180
+ field = model._meta.get_field(field_name)
181
+ if not isinstance(field, (models.ForeignKey, models.ManyToManyField)):
182
+ raise TypeError(f"Field {field_name} must be a ForeignKey or ManyToManyField.")
183
+ except FieldDoesNotExist:
184
+ raise ValueError(
185
+ f"Couldn't find a field named {field_name} in {model._meta.object_name}."
186
+ )
187
+
188
+ @staticmethod
189
+ def get_related_model(model, field_name) -> models.Model:
190
+ """Given a model and the name of a ForeignKey or ManyToManyField column
191
+ as a string, returns the associated model.
192
+ """
193
+ try:
194
+ return model._meta.get_field(field_name).remote_field.model
195
+ except AttributeError:
196
+ return model._meta.get_field(field_name).related_model
@@ -0,0 +1,36 @@
1
+ from django.contrib.sites.models import Site
2
+ from django.core.exceptions import ObjectDoesNotExist
3
+ from django.db import connections, router
4
+ from django.db.models.signals import post_save, pre_save
5
+ from django.dispatch import receiver
6
+
7
+ from multisite.utils import create_or_sync_alias_from_site
8
+
9
+
10
+ @receiver(pre_save, sender=Site, weak=False, dispatch_uid="site_domain_changed_hook")
11
+ def pre_save_site_domain_changed(sender, instance, raw, *args, **kwargs):
12
+ """Updates canonical Alias object if Site.domain has changed."""
13
+ if not raw and instance.pk is not None:
14
+ try:
15
+ original = sender.objects.get(pk=instance.pk)
16
+ except ObjectDoesNotExist:
17
+ pass
18
+ else:
19
+ # Update Alias.domain to match site
20
+ if original.domain != instance.domain:
21
+ create_or_sync_alias_from_site(site=instance)
22
+
23
+
24
+ @receiver(post_save, sender=Site, weak=False, dispatch_uid="site_created_hook")
25
+ def post_save_site_created(sender, instance, raw, created, *args, **kwargs):
26
+ """Creates canonical Alias object for a new Site."""
27
+ if not raw and created:
28
+ # When running create_default_site() because of post_syncdb,
29
+ # don't try to sync before the db_table has been created.
30
+ using = router.db_for_write(sender)
31
+ tables = connections[using].introspection.table_names()
32
+ if sender._meta.db_table not in tables:
33
+ pass
34
+ else:
35
+ # Update Alias.domain to match site
36
+ create_or_sync_alias_from_site(site=instance)
@@ -0,0 +1,14 @@
1
+ from django.core.exceptions import ValidationError
2
+
3
+ __all__ = ["validate_1_or_none"]
4
+
5
+
6
+ def validate_1_or_none(value: bool) -> None:
7
+ """Raises ValidationError if value is not int(1) or None."""
8
+ if value not in [1, None]:
9
+ raise ValidationError("%r must be 1 or None" % value)
10
+
11
+
12
+ def validate_true_or_none(value: bool) -> None:
13
+ # leave for old migrations
14
+ pass
File without changes
@@ -0,0 +1,14 @@
1
+ def get_test_allowed_hosts(*extra, replace=None):
2
+ if replace:
3
+ allowed_hosts = []
4
+ else:
5
+ allowed_hosts = [
6
+ ".extrahost.com",
7
+ "app.test1.example.com",
8
+ "app.test2.example.com",
9
+ "example.com",
10
+ "new.app.test3.example.com",
11
+ "test.example.com",
12
+ ]
13
+ allowed_hosts.extend(extra)
14
+ return allowed_hosts
@@ -0,0 +1,13 @@
1
+ from typing import Callable, Type
2
+
3
+ from django.http import HttpResponse
4
+
5
+
6
+ def get_test_http_response(*cookies) -> Callable:
7
+ def http_response(request) -> HttpResponse:
8
+ response = HttpResponse()
9
+ for cookie in cookies:
10
+ response.set_cookie(**cookie)
11
+ return response
12
+
13
+ return http_response
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from django.utils.functional import SimpleLazyObject, empty
4
+
5
+ __all__ = ["ALLOWED_HOSTS", "AllowedHosts"]
6
+
7
+ _wrapped_default = empty
8
+
9
+
10
+ def get_multisite_extra_hosts():
11
+ from django.conf import settings
12
+
13
+ return getattr(settings, "MULTISITE_EXTRA_HOSTS", [])
14
+
15
+
16
+ class IterableLazyObject(SimpleLazyObject):
17
+ _wrapped_default = globals()["_wrapped_default"]
18
+
19
+ def __iter__(self):
20
+ if self._wrapped is self._wrapped_default:
21
+ self._setup()
22
+ return self._wrapped.__iter__()
23
+
24
+
25
+ class AllowedHosts(object):
26
+ alias_model = None
27
+
28
+ def __init__(self):
29
+ self.extra_hosts = get_multisite_extra_hosts()
30
+ if self.alias_model is None:
31
+ from multisite.models import Alias
32
+
33
+ self.alias_model = Alias
34
+
35
+ def __iter__(self):
36
+ # Yielding extra hosts before actual hosts because there might be
37
+ # wild cards in there that would prevent us from doing a database
38
+ # query every time.
39
+ for host in self.extra_hosts:
40
+ yield host
41
+
42
+ for host in self.alias_model.objects.values_list("domain"):
43
+ yield host[0]
44
+
45
+
46
+ ALLOWED_HOSTS = IterableLazyObject(lambda: AllowedHosts())
@@ -0,0 +1,23 @@
1
+ from multisite import SiteID
2
+
3
+ SILENCED_SYSTEM_CHECKS = ["sites.E101"]
4
+ SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" # nosec B105
5
+ SITE_ID = SiteID(default=1)
6
+ DATABASES = {
7
+ "default": {
8
+ "ENGINE": "django.db.backends.sqlite3",
9
+ "NAME": ":memory:",
10
+ }
11
+ }
12
+
13
+ INSTALLED_APPS = [
14
+ # "django.contrib.auth",
15
+ # "django.contrib.contenttypes",
16
+ "django.contrib.sites",
17
+ "multisite",
18
+ "multisite_app",
19
+ ]
20
+ MIDDLEWARE = [
21
+ "multisite.middleware.DynamicSiteMiddleware",
22
+ "django.contrib.sites.middleware.CurrentSiteMiddleware",
23
+ ]
File without changes
@@ -0,0 +1,12 @@
1
+ from django.test.client import RequestFactory as DjangoRequestFactory
2
+
3
+
4
+ class RequestFactory(DjangoRequestFactory):
5
+ def __init__(self, host):
6
+ super().__init__()
7
+ self.host = host
8
+
9
+ def get(self, path, data={}, host=None, **extra):
10
+ if host is None:
11
+ host = self.host
12
+ return super().get(path=path, data=data, HTTP_HOST=host, **extra)