skip to content

In a Django template, how do you show a Refund button only to finance staff, and why is hiding it not enough?

level: juniorimportance: should knowfreq 48%

answer

  1. a context processor supplies it
  2. app label, then codename, as attributes
  3. markup is not a lock
  4. the URL still accepts a POST

basics

~20 s

Wrap the button in {% if perms.payments.refund_payment %}; the perms variable from the auth context processor calls user.has_perm(). Hiding is cosmetic: anyone can still POST to the refund URL, so the view must enforce the same permission.

solid answer

~40 s

`perms` is a `PermWrapper` that `django.contrib.auth.context_processors.auth` adds to every template rendered **with a request**; `startproject` enables that processor. `{% if perms.payments.refund_payment %}` calls `user.has_perm("payments.refund_payment")`, `{% if perms.payments %}` calls `has_module_perms("payments")` (any permission in that app), and `{% if "payments.refund_payment" in perms %}` works too. That only decides which HTML is sent. The refund endpoint is still a URL any logged-in user can POST to from their own session, so the view that performs the refund must be guarded with `permission_required` or `PermissionRequiredMixin` using the same permission string, ideally with a test proving a non-finance user gets a 403. The template is for the user experience; the view is the enforcement point.

code

django · 6 lines
django
{% if perms.payments.refund_payment %}
  <form method="post" action="{% url 'payments:refund' payment.pk %}">
    {% csrf_token %}
    <button type="submit">Refund</button>
  </form>
{% endif %}

go deeper

for a junior

Recall the perms.app_label.codename syntax inside an if tag and say plainly that the view must still check the same permission.

for a middle

Explain that the auth context processor supplies perms only when rendering with a request, and which lookup calls has_module_perms versus has_perm.

for a senior

Point out that superusers pass any permission string, so template typos survive admin testing, and propose tests that keep template and view checks in step.

for a principal

Frame templates as user experience and views as the enforcement point, and make security that exists only in markup a review blocker.

## Where `{{ perms }}` comes from Django's auth app ships a **context processor**, `django.contrib.auth.context_processors.auth`, that the settings generated by `startproject` already list under `TEMPLATES[...]["OPTIONS"]["context_processors"]`. It adds two variables to every template context: - `user`: the current `request.user`, or an `AnonymousUser`; - `perms`: a `PermWrapper`, a template-friendly proxy around that user's permission checks. Context processors run only when a template is rendered **with a request**: `render(request, ...)`, a `TemplateResponse` returned by a generic view, or `render_to_string(..., request=request)`. A template rendered without a request (an email body built with `render_to_string(name, context)`) has no `perms` at all, and any `{% if perms... %}` in it quietly evaluates false. ## The lookup forms | Template expression | Calls on the user | True when | |---|---|---| | `perms.payments` | `has_module_perms("payments")` | the user holds any permission in the `payments` app | | `perms.payments.refund_payment` | `has_perm("payments.refund_payment")` | the user holds that permission | | `"payments" in perms` | `has_module_perms("payments")` | same as the first row | | `"payments.refund_payment" in perms` | `has_perm("payments.refund_payment")` | same as the second row | The single-level lookup is not a permission called `payments`; it is a question about the whole app, which is handy for showing or hiding a navigation section. Active superusers get `True` from every one of these. ## Hiding is not enforcing Wrapping the Refund form in `{% if perms.payments.refund_payment %}` means the server never sends that markup to someone without the permission. It does not stop the request the form would have made: 1. A support agent learns the refund URL, from a colleague's screen, a browser history or simply the URL pattern. 2. Logged in as themselves, they submit a POST to it. CSRF protection does not help here: it stops **other sites** forging requests, not a logged-in user sending their own. 3. If the view behind the URL has no check of its own, the refund goes through. So every state-changing view needs its own guard: `@permission_required("payments.refund_payment", raise_exception=True)` on a function view or `PermissionRequiredMixin` on a class-based one. The template check and the view check should use the **same** permission string, and a test that posts as an unprivileged user and expects a 403 keeps the pair honest. ## Pitfalls - **Typos fail silently.** `perms.payments.refund_paymnt` calls `has_perm()` with a string nobody holds, so the button vanishes for every normal user without any error. Superusers still see it, which is why the mistake survives testing as an admin. - **No request, no `perms`.** Emails, PDFs and cached fragments rendered without a request lose both `user` and `perms`. - **It is not iterable.** `{% for p in perms %}` raises `TypeError`; the wrapper answers questions, it does not list permissions. - **It is per-user, not per-object.** `perms` answers model-level questions only; whether this user may refund *this* payment is a separate, object-level check. ## Putting it together A sound refund feature has three pieces that agree with each other: - the template shows the button only when `perms.payments.refund_payment` is true; - the view that performs the refund enforces `payments.refund_payment` itself; - a test logs in as a user without the permission, posts to the refund URL and asserts a 403. The first improves the interface; only the second and third provide security. ## Keeping the checks in step The template and the view drift apart when the permission string is typed in two places. Practical habits that prevent it: - keep the string in one place, such as a module constant the view imports, and pass a boolean like `can_refund` into the context when the template needs more than a direct lookup; - review template conditions and view guards together whenever a permission is renamed; - test as a regular user with exactly the grants a finance clerk has, not as a superuser, because a superuser passes every check and hides both typos and missing guards.

  • Why does {% if perms.payments.refund_paymnt %} (a typo) raise no error?
    The lookup calls `has_perm("payments.refund_paymnt")`, and `has_perm()` does not validate names; an unknown string is simply not among the user's permissions, so the result is `False` and the button disappears. Active superusers get `True` for any string, so developers testing as an admin still see the button and miss the typo.
  • Why might perms be empty in a template rendered for an email?
    Context processors, including the auth one that supplies `perms` and `user`, run only when a request is passed to the render call. `render_to_string(name, context)` without `request=` builds a plain context, so `perms` is missing and every check on it is false. Pass the request, or decide in Python and put a boolean in the context.

Hiding the button is like taking the sign off a door but leaving it unlocked: people who never knew the room existed will not try it, yet anyone who knows where it is can still walk in. The view's permission check is the lock.

saying these in an interview costs you the question

  • If the refund button is hidden, non-finance users cannot issue refunds.
  • perms.payments checks for a permission literally named payments.
  • A misspelled permission in the template raises an error at render time.
  • perms is available in every template, even one rendered without a request.
  • CSRF protection stops a logged-in user from posting to a view they were not shown.