Mixins

Pengin-Pi-3 leans on mixins heavily, in both models and views. The premise is simple: keep behavior modular, compose it onto a class when that class needs it, and never write the same rate-limit check or audit-snapshot twice.

A view's declaration should read as a list of what it does:

class SignupView(RateLimitedPostMixin, RateLimitedGetMixin,
                 RecaptchaRequiredMixin, View):

Rate-limited both ways, bot-checked on submit. No boilerplate in the body, no decorator stack, nothing to forget on the next view.


How they work

Almost every view mixin here follows the same shape: override dispatch(), do a check, and either short-circuit or call super().dispatch().

class SomethingRequiredMixin:
    def dispatch(self, request, *args, **kwargs):
        if request.method == 'POST' and not self.check(request):
            messages.error(request, "Nope.")
            return redirect(request.path)
        return super().dispatch(request, *args, **kwargs)

Because each one calls super(), they chain. Django walks the MRO left to right, so the leftmost mixin runs its check first.

Order therefore matters, and it's a design decision, not a formality. Put cheap local checks left of expensive remote ones. RateLimitedPostMixin sits left of RecaptchaRequiredMixin throughout the codebase so a flood of submissions is rejected from cache before any of them reach out to Google's verification API.

View always goes last.


Where they live

| Location | Contains | Rule | |---|---|---| | main/auth/mixins.py | Authentication and permission guards | Anything about who the user is or what they may do | | util/mixins.py | Generic view behavior | Explicitly auth-free | | util/security/ | Rate limiting, reCAPTCHA | Abuse prevention | | main/models/mixins.py | Model mixins | Auditing, sitemap opt-in |

The split between the first two is enforced by convention and worth keeping: util/ is generic infrastructure with no auth logic in it. If your mixin needs to know about roles or permissions, it belongs in main/auth/. See RBAC.


View mixins

RateLimitedPostMixin

util/security/ratelimit.py · default 5/m

Per-IP rate limit on POST. Keyed on the real client IP resolved through the proxy chain, grouped by the view class name so each view gets its own bucket. Over the limit, the user gets an error message and a redirect back.

class SlugEditView(LoginRequiredMixin, RateLimitedPostMixin, View):
    ratelimit_rate = '20/m'

Applied to every POST that matters — login, signup, password reset, profile edit, slug create/edit/delete — staff-only forms included. Being logged in doesn't rule out scripted abuse.

It fails open. The whole check is wrapped in a bare except Exception: pass. If Redis is down, requests go through unlimited rather than locking everyone out of login. That's the right trade for availability, but know that a cache outage silently disables rate limiting.

RateLimitedGetMixin

util/security/ratelimit.py · default 10/m

Same, for GET. Useful on pages that are cheap to request and expensive to serve, or that leak information under enumeration.

Gotcha: both mixins read the same ratelimit_rate attribute. A class using both cannot set them independently — LoginView declares ratelimit_rate = '5/m' and gets 5/m on GET as well as POST, not the 10/m GET default. If you need different rates, you currently have to subclass one of the mixins to rename its attribute.

RecaptchaRequiredMixin

util/security/recaptcha.py · default recaptcha_min_score = 0.5

Verifies a reCAPTCHA v3 token on POST. The token comes from g-recaptcha-response; a failure produces an error message and a redirect.

class SignupView(RateLimitedPostMixin, RateLimitedGetMixin,
                 RecaptchaRequiredMixin, View):
    recaptcha_min_score = 0.7

Applied to anonymous-facing forms with real consequences. The template side needs the site key in context — see the auth templates for the pattern.

It degrades gracefully. With no keys configured, RECAPTCHA_ENABLED is false and verification is skipped as passing, so development environments aren't locked out of every form. Verification failures and network errors both return false.

The underlying verify_recaptcha_token() is also callable directly, which is how SlugView.dispatch() gates POSTs to pages that set requires_recaptcha.

RedisLoggingMixin

util/mixins.py · prefix request_log, TTL 3600s

Writes a per-request log entry to Redis — user id, email, method, path, IP — under a millisecond-keyed key with a one-hour expiry. Short-lived request tracing, distinct from the analytics middleware's durable page-view logging.

It provides log_request(request) but does not override dispatch(), so it does nothing until something calls it. SuperTemplateView is what does:

class SuperTemplateView(RedisLoggingMixin, View):
    def dispatch(self, request, *args, **kwargs):
        self.log_request(request)
        return super().dispatch(request, *args, **kwargs)

Extend SuperTemplateView instead of View to get it. SlugView does, which means every page request is traced.

Auth guards

Covered in detail in RBAC, listed here for completeness:

  • StaffRequiredMixin — authenticated and is_staff
  • LoginAndValidationRequiredMixin — authenticated and email-validated
  • PublicEventsOrLoggedInMixin — lets validated users through, limits anonymous visitors to public events within a year

Django's own LoginRequiredMixin is used directly where a plain login check is all that's needed.


Model mixins

HistoryMixin

main/models/mixins.py

Adds save_history(user), which snapshots the object's pre-change field values into a paired History model. Requires a companion model subclassing AbstractHistory.

class Report(HistoryMixin, models.Model):
    ...

Full documentation — the companion model boilerplate, the call-before-you-save rule, encrypted field handling, and the gaps — is in History and Auditing.

SitemapEntry

main/models/mixins.py

Not behavior, a marker. Subclass it to opt a model into the public sitemap:

class Article(SitemapEntry, HistoryMixin, models.Model):
    sitemap_lastmod_field = 'updated_at'

    def get_absolute_url(self):
        return f"/articles/{self.slug}/"

It's explicit opt-in on purpose. The sitemap previously duck-typed on get_absolute_url(), which let unrelated models leak in and out unpredictably as methods were added and removed. get_absolute_url() raises NotImplementedError if you subclass without defining it.

See SEO and Sitemaps.


Composition in practice

Real declarations from the codebase, read left to right as an execution order:

class LoginView(RateLimitedPostMixin, RateLimitedGetMixin,
                RecaptchaRequiredMixin, View)

Throttle POSTs, throttle GETs, then verify the token. Cheap checks first.

class SlugCreateView(LoginRequiredMixin, RateLimitedPostMixin, View)

Reject anonymous users before spending a cache round-trip on rate limiting.

class SlugView(RateLimitedPostMixin, SuperTemplateView)

Throttle, then log and render. SuperTemplateView carries the logging.

class StaffUserEditView(StaffRequiredMixin, View)

The staff console relies entirely on the auth mixin; no view-level permission code at all.


Writing your own

Follow the established shape:

# util/mixins.py — or main/auth/mixins.py if it's about permissions

class AuditedPostMixin:
    """One line on what it does and when to use it."""
    audit_category = 'general'

    def dispatch(self, request, *args, **kwargs):
        if request.method == 'POST':
            record_audit(request, self.audit_category)
        return super().dispatch(request, *args, **kwargs)

Conventions worth keeping:

  • Always call super().dispatch() unless you're deliberately short-circuiting. Forgetting it silently breaks every mixin to your right.
  • Configure through class attributes, with a sensible default on the mixin. ratelimit_rate and recaptcha_min_score are the pattern.
  • Guard on method. A POST-only check that runs on GET is a bug waiting for a page view.
  • Give attributes distinct names. The shared ratelimit_rate above is the cautionary example — two mixins, one attribute, no way to configure them separately.
  • Decide fail-open or fail-closed deliberately and say which in the docstring. Rate limiting fails open for availability; a permission check must never do that.
  • Auth-free mixins go in util/. If it needs to know about roles, it belongs in main/auth/.

For model mixins, plain Python classes are fine — HistoryMixin doesn't subclass models.Model, it just adds a method. Only inherit from models.Model if you're adding fields, and make it abstract if you do.


Next

Pages Here

No sub-pages yet.

Page Info

Wiki: Docs

Created on Sep 21, 2026 by Stuart Anderson

Maintainers

Editor Last Activity
Stuart Anderson creator Sep 21, 2026