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 andis_staffLoginAndValidationRequiredMixin— authenticated and email-validatedPublicEventsOrLoggedInMixin— 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_rateandrecaptcha_min_scoreare 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_rateabove 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 inmain/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
- History and Auditing —
HistoryMixinin full - RBAC — the permission guards
- Architecture — where these sit in the request path
- Contributing — conventions for new code
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 |