Django Intermediate #4: Users/Permissions — custom user model, permission, group

7 min read

In Basics #7, we covered Django’s built-in authentication — login/logout, @login_required, the User model. As soon as you get into real work, two questions come up.

  1. “What if I want to log in by email instead of username?”
  2. “How do I give users fine-grained permissions?”

This post is the answer. It starts with one heavy warning — AUTH_USER_MODEL must be decided at project start.

Heavy warning — a decision to make at the start #

The official Django docs put it plainly: “Even if you’re happy with the default User model, you’ll likely want to change it later. So if you’re starting a new project, definitely set up a custom user model, even if it looks just like Django’s default user model.”

The reason is that changing it after running migrations is very hard — you’d have to update every foreign key, the full migration history, and all existing data. For a new project, set the following two things before the first migration.

settings.py
AUTH_USER_MODEL = "accounts.User"
accounts/models.py — minimal
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass

Even if you don’t need extra fields right now, start with your own User class. Adding fields later is easy.

AbstractUser vs AbstractBaseUser #

There are two paths depending on customization depth.

AbstractUserAbstractBaseUser + PermissionsMixin
Built-in fieldsusername, email, first_name, last_name, …password, last_login only
Permission systemAuto-includedAdd via PermissionsMixin
When to chooseMost cases (90%) — small additionsCompletely different user model (email only, etc.)
WorkloadVery littleLots (write your own UserManager)

AbstractUser is the answer almost everywhere. Use AbstractBaseUser only when you really want to drop username and use email exclusively.

AbstractUser — just adding fields #

accounts/models.py — adding fields
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return self.username

That’s it. username, email, first_name, last_name, is_staff, is_active, etc. are all still there.

Email-login pattern — AbstractBaseUser #

A common real-world requirement — using email as username. The standard here is AbstractBaseUser + a custom manager.

accounts/models.py — email login
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone


class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra):
        if not email:
            raise ValueError("Email is required.")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra):
        extra.setdefault("is_staff", False)
        extra.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra)

    def create_superuser(self, email, password=None, **extra):
        extra.setdefault("is_staff", True)
        extra.setdefault("is_superuser", True)
        if extra.get("is_staff") is not True:
            raise ValueError("superuser must have is_staff=True.")
        if extra.get("is_superuser") is not True:
            raise ValueError("superuser must have is_superuser=True.")
        return self._create_user(email, password, **extra)


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=100, blank=True)

    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = UserManager()

    USERNAME_FIELD = "email"        # the field used for login
    REQUIRED_FIELDS = []            # extra fields createsuperuser asks for (besides email/password)

    def __str__(self):
        return self.email

Checkpoints:

  • USERNAME_FIELD = "email" — login identifier. unique=True is required
  • REQUIRED_FIELDS — extra fields createsuperuser will prompt for. USERNAME_FIELD and password are auto-included, so don’t list them here
  • UserManager’s create_user / create_superuser are called by manage.py createsuperuser and friends
  • PermissionsMixin must also be inherited for the group/permission system to work

admin registration #

Even if createsuperuser works, the admin’s user-change form is based on the default User model and will break. Register a custom UserAdmin instead.

accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User


class UserAdmin(BaseUserAdmin):
    ordering = ("email",)
    list_display = ("email", "name", "is_staff", "is_active")
    search_fields = ("email", "name")

    fieldsets = (
        (None, {"fields": ("email", "password")}),
        ("Personal info", {"fields": ("name",)}),
        ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser",
                          "groups", "user_permissions")}),
        ("History", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (None, {"classes": ("wide",),
                "fields": ("email", "password1", "password2")}),
    )

admin.site.register(User, UserAdmin)

For signup and login forms, the username-based built-in forms won’t work as-is either — subclass UserCreationForm / AuthenticationForm and rebuild them around email.

Permission system #

Django automatically creates 4 permissions per model.

Permission codeMeaning
add_<model>Create
change_<model>Update
delete_<model>Delete
view_<model>View

For the Post model in the blog app, blog.add_post, blog.change_post, blog.delete_post, blog.view_post are auto-generated.

Checking in code #

using has_perm
if request.user.has_perm("blog.change_post"):
    # has change permission
    ...

if request.user.has_perms(["blog.change_post", "blog.delete_post"]):
    # has both
    ...

Decorator / Mixin #

FBV — @permission_required
from django.contrib.auth.decorators import permission_required

@permission_required("blog.change_post", raise_exception=True)
def post_edit(request, pk):
    ...
CBV — PermissionRequiredMixin
from django.contrib.auth.mixins import PermissionRequiredMixin

class PostUpdateView(PermissionRequiredMixin, UpdateView):
    permission_required = "blog.change_post"
    raise_exception = True

The Mixin we saw in #1 CBV.

Custom permissions #

You can define them in the model Meta to add to the auto-generated set.

custom permissions
class Post(models.Model):
    ...
    class Meta:
        permissions = [
            ("publish_post", "Can publish post"),
            ("feature_post", "Can feature post on homepage"),
        ]

After makemigrations, use as blog.publish_post. Assign through the admin’s user/group editor.

Group — bundles of permissions #

Assigning permissions directly to individual users gets unmanageable fast. Bundle them into groups and assign the group to users — that’s the standard approach.

creating a group (shell/migration)
from django.contrib.auth.models import Group, Permission

editors, _ = Group.objects.get_or_create(name="Editor")
editors.permissions.add(
    Permission.objects.get(codename="add_post"),
    Permission.objects.get(codename="change_post"),
    Permission.objects.get(codename="publish_post"),
)

# assign group to user
user.groups.add(editors)

You can do the same in the admin’s Groups menu. For reproducibility, it’s good practice to bake group/permission seed values into code via a data migration or seed command.

creating groups via data migration
from django.db import migrations

def create_default_groups(apps, schema_editor):
    Group = apps.get_model("auth", "Group")
    Permission = apps.get_model("auth", "Permission")
    editors, _ = Group.objects.get_or_create(name="Editor")
    perms = Permission.objects.filter(
        codename__in=["add_post", "change_post", "publish_post"]
    )
    editors.permissions.set(perms)

class Migration(migrations.Migration):
    dependencies = [("blog", "0001_initial")]
    operations = [migrations.RunPython(create_default_groups, migrations.RunPython.noop)]

Checking groups/permissions in templates #

template
{% if user.is_authenticated %}
  {% if perms.blog.publish_post %}
    <a href="{% url 'post_publish' post.pk %}">Publish</a>
  {% endif %}
{% endif %}

perms is a helper passed by the django.contrib.auth.context_processors.auth context processor. If it’s in settings.py’s TEMPLATES.OPTIONS.context_processors, it’s automatic.

Object-level permission — one-line note #

Django’s built-in permissions are per-model — only “can this user change any Post”. “Can this user change this specific Post” (per-object) is not provided out of the box.

If you need per-object permissions:

  • UserPassesTestMixin (#1) for inline checks — sufficient for small cases
  • django-guardian external library — full-blown object permissions
  • DRF’s permission_classes — for REST APIs (DRF #2)

For small cases, an inline check is clean.

only the author can edit
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    form_class = PostForm

    def test_func(self):
        return self.get_object().author == self.request.user

Passwords — hash / change / reset #

Django automatically stores passwords with safe hashes like PBKDF2 (default). You rarely have to touch them directly.

working with passwords
user.set_password("new password")    # hashes and stores
user.save()

user.check_password("input")          # verify

Never store plain-text passwords. Direct assignment like user.password = "..." writes plaintext directly, so always use set_password.

The built-in password change/reset views are in django.contrib.auth.urls.

urls.py
from django.urls import path, include

urlpatterns = [
    path("accounts/", include("django.contrib.auth.urls")),
    # /accounts/login/, /accounts/logout/, /accounts/password_change/,
    # /accounts/password_reset/, etc.
]

Difference from DRF auth — briefly #

This post’s auth is the session + cookie (browser form login) model. REST APIs usually use stateless auth like token / JWT / OAuth2.

Django built-in (this post)DRF
Auth mediumSession cookieToken / JWT / OAuth2
Permission checkhas_perm / Mixin / decoratorpermission_classes
User modelSame (AUTH_USER_MODEL)Same
Login formBuilt-in HTMLSeparate endpoint

Same User model, but with a different auth medium and a different permission-check layer — that is the structure. The DRF auth/permission stack is detailed in DRF #2.

What request.user actually is #

AuthenticationMiddleware (the middleware seen in #3) populates request.user on every request.

  • Logged inUser instance
  • Not logged inAnonymousUser (a placeholder object; is_authenticated == False)
common checks
if request.user.is_authenticated:
    ...

if request.user.is_staff:
    ...

if request.user.is_superuser:
    ...

is_authenticated is an attribute (not a method). Don’t call it as request.user.is_authenticated() — in old Django 1.x it was a method, but now it’s an attribute.

Summary #

What we covered in this post:

  • AUTH_USER_MODEL is set at project start — changing it later is very expensive
  • AbstractUser is the answer almost everywhere (just add fields)
  • Email login — AbstractBaseUser + PermissionsMixin + custom UserManager
  • The meaning of USERNAME_FIELD, REQUIRED_FIELDS
  • Auto 4 permissions per model (add/change/delete/view_<model>)
  • has_perm, @permission_required, PermissionRequiredMixin
  • Custom permissions via model Meta.permissions
  • Bundle permissions via Group — assign the group to users
  • Object-level permission via UserPassesTestMixin or django-guardian / DRF
  • Passwords — set_password / check_password, built-in change/reset views
  • request.user.is_authenticated is an attribute

In the next post (#5 Messages/sessions/cookies), we cover flash messages sent to authenticated users, sessions for state across requests, and the cookies beneath them — all in one post.

X