Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import re
  4from datetime import datetime
  5from typing import TYPE_CHECKING
  6
  7from plain import models
  8from plain.models import types
  9
 10if TYPE_CHECKING:
 11    from plain.http import Request
 12
 13
 14@models.register_model
 15class Redirect(models.Model):
 16    from_pattern: str = types.CharField(max_length=255)
 17    to_pattern: str = types.CharField(max_length=255)
 18    http_status: int = types.PositiveSmallIntegerField(
 19        default=301
 20    )  # Default to permanent - could be choices?
 21    created_at: datetime = types.DateTimeField(auto_now_add=True)
 22    updated_at: datetime = types.DateTimeField(auto_now=True)
 23    order: int = types.PositiveSmallIntegerField(default=0)
 24    enabled: bool = types.BooleanField(default=True)
 25    is_regex: bool = types.BooleanField(default=False)
 26
 27    # query params?
 28    # logged in or not? auth not required necessarily...
 29    # headers?
 30
 31    query: models.QuerySet[Redirect] = models.QuerySet()
 32
 33    model_options = models.Options(
 34        ordering=["order", "-created_at"],
 35        indexes=[
 36            models.Index(fields=["order"]),
 37            models.Index(fields=["created_at"]),
 38        ],
 39        constraints=[
 40            models.UniqueConstraint(
 41                fields=["from_pattern"],
 42                name="plainredirects_redirect_unique_from_pattern",
 43            ),
 44        ],
 45    )
 46
 47    def __str__(self) -> str:
 48        return f"{self.from_pattern}"
 49
 50    def matches_request(self, request: Request) -> bool:
 51        """
 52        Decide whether a request matches this Redirect,
 53        automatically checking whether the pattern is path based or full URL based.
 54        """
 55
 56        if self.from_pattern.startswith("http"):
 57            # Full url with query params
 58            url = request.build_absolute_uri()
 59        else:
 60            # Doesn't include query params or host
 61            url = request.path
 62
 63        if self.is_regex:
 64            return bool(re.match(self.from_pattern, url))
 65        else:
 66            return url == self.from_pattern
 67
 68    def get_redirect_url(self, request: Request) -> str:
 69        if not self.is_regex:
 70            return self.to_pattern
 71
 72        # Replace any regex groups in the to_pattern
 73        if self.from_pattern.startswith("http"):
 74            url = request.build_absolute_uri()
 75        else:
 76            url = request.path
 77
 78        return re.sub(self.from_pattern, self.to_pattern, url)
 79
 80
 81@models.register_model
 82class RedirectLog(models.Model):
 83    redirect: Redirect = types.ForeignKeyField(Redirect, on_delete=models.CASCADE)
 84
 85    # The actuals that were used to redirect
 86    from_url: str = types.URLField(max_length=512)
 87    to_url: str = types.URLField(max_length=512)
 88    http_status: int = types.PositiveSmallIntegerField(default=301)
 89
 90    # Request metadata
 91    ip_address: str = types.GenericIPAddressField()
 92    user_agent: str = types.CharField(required=False, max_length=512)
 93    referrer: str = types.CharField(required=False, max_length=512)
 94
 95    created_at: datetime = types.DateTimeField(auto_now_add=True)
 96
 97    query: models.QuerySet[RedirectLog] = models.QuerySet()
 98
 99    model_options = models.Options(
100        ordering=["-created_at"],
101        indexes=[
102            models.Index(fields=["created_at"]),
103        ],
104    )
105
106    @classmethod
107    def from_redirect(cls, redirect: Redirect, request: Request) -> RedirectLog:
108        from_url = request.build_absolute_uri()
109        to_url = redirect.get_redirect_url(request)
110
111        if not to_url.startswith("http"):
112            to_url = request.build_absolute_uri(to_url)
113
114        if from_url == to_url:
115            raise ValueError("Redirecting to the same URL")
116
117        return cls.query.create(
118            redirect=redirect,
119            from_url=from_url,
120            to_url=to_url,
121            http_status=redirect.http_status,
122            ip_address=request.client_ip,
123            user_agent=request.headers.get("User-Agent", ""),
124            referrer=request.headers.get("Referer", ""),
125        )
126
127
128@models.register_model
129class NotFoundLog(models.Model):
130    url: str = types.URLField(max_length=512)
131
132    # Request metadata
133    ip_address: str = types.GenericIPAddressField()
134    user_agent: str = types.CharField(required=False, max_length=512)
135    referrer: str = types.CharField(required=False, max_length=512)
136
137    created_at: datetime = types.DateTimeField(auto_now_add=True)
138
139    query: models.QuerySet[NotFoundLog] = models.QuerySet()
140
141    model_options = models.Options(
142        ordering=["-created_at"],
143        indexes=[
144            models.Index(fields=["created_at"]),
145        ],
146    )
147
148    @classmethod
149    def from_request(cls, request: Request) -> NotFoundLog:
150        return cls.query.create(
151            url=request.build_absolute_uri(),
152            ip_address=request.client_ip,
153            user_agent=request.headers.get("User-Agent", ""),
154            referrer=request.headers.get("Referer", ""),
155        )