student-management-system-django-drf: A Django API Where Roles Shape the Whole App

A close look at a student management backend that uses permissions, scoped querysets, serializer validation, and signals to turn RBAC into a system design pattern.

8 to 10 min read • View on GitHub • More from AkhnasFurqan-Dev

A schoolhouse-style control room with three conveyor belts labeled Admin, Teacher, and Student feeding into one central gate. Beyond the gate are three locked rooms for course assignment, enrollment records, and notification dispatch, with a sealed envelope passing through the handoff point. The scene explains how this API makes roles shape access, data visibility, and side effects at the system level.
Roles do not sit beside the app. They define the shape of it.
Key Takeaways

The one thing this repo gets oddly right

The sharpest detail in this project is also the most controversial: when an admin creates a user, the system briefly carries the raw password long enough to email it once, then lets hashing do its job. That is not a cute trick. It is a real design answer to a real onboarding problem.

Most starter CRUD apps stop at "create user" and leave the rest to manual coordination. This repo goes further. It tries to make the first day of a new account feel automatic, while still keeping the password inaccessible after creation.

That choice sets the tone for the whole codebase. The interesting question is not whether the app has roles. It is how deeply those roles shape every layer that follows.

Roles are the data model

The custom user model is the foundation. Instead of treating admin, teacher, and student as labels applied later, the repo encodes them as a first-class role field on the user itself. That makes authorization part of the domain model, not a decorator on top of it.

class User(AbstractUser):
    class Role(models.TextChoices):
        ADMIN = "admin", "Admin"
        TEACHER = "teacher", "Teacher"
        STUDENT = "student", "Student"

    role = models.CharField(
        max_length=20,
        choices=Role.choices,
        default=Role.STUDENT,
    )

That matters because downstream code can rely on the role being explicit and enumerable. Permissions become readable. Query filtering becomes deterministic. Serializer rules can branch on the same shared source of truth.

The same role decision keeps reappearing at different layers, each one catching a different failure mode.

Permissions are only the first gate

The repo uses custom permission classes like `IsAdmin`, `IsTeacher`, and `IsStudent`, which makes the intent obvious at the view boundary. But the more important move is that permissions do not carry the whole burden. They only decide whether a request may continue.

LayerWhat it protectsWhat breaks if removedWhat role it serves
Permission classesWho may enter the endpointUnauthorized requests can reach protected viewsFirst gate
Queryset scopingWhich objects exist for the requesterUsers can see records that are not theirsData visibility
Serializer validationWhich fields may changeLegitimate updates can become privilege creepMutation control
SignalsWhat happens after save or deleteViews become bloated with email and notification logicSide effects

That division is the real architecture lesson. A secure DRF app does not rely on a single yes or no. It layers several smaller decisions, each with a narrow job.

Visibility changes by role

Once a request clears the permission class, the queryset decides what the user can actually see. That is where the app becomes contextual. Students see only their own records. Teachers see the records tied to their courses. Admins see the full dataset.

A courthouse-style ledger split into three stamped columns, with one stamp reading permission granted, another queryset filtered, and a third serializer blocked. A thin thread runs through all three columns to show the same role policy being enforced at multiple layers. The image explains why authorization in this app is layered rather than centralized.
One role decision, three different enforcement layers, three different outcomes.
RoleWhat they can seeWhat they can changeWhat they trigger
StudentOnly their own enrollments and profile dataOnly fields allowed by serializer rulesTheir own notifications
TeacherTheir courses and related enrollmentsTeacher-facing fields and course assignment contextCourse and enrollment updates
AdminEverythingUser creation and broad management actionsProvisioning and account emails

That is the hidden strength of the repo. Authorization is not binary. It is data-shaped. The API does not merely ask who you are. It asks which records should exist in your world.

Serializer validation stops privilege creep

The `UserProfileSerializer.validate()` method is where policy becomes stubborn. Even if a user reaches an update endpoint legitimately, the serializer can still reject changes to fields that should not move. In this codebase, that includes sensitive student attributes such as roll number and enrollment year.

class UserProfileSerializer(serializers.ModelSerializer):
    def validate(self, attrs):
        user = self.context["request"].user

        if user.role == "student":
            blocked = {"roll_number", "enrollment_year"}
            if blocked.intersection(attrs.keys()):
                raise serializers.ValidationError(
                    "Students cannot change academic identity fields."
                )

        return attrs

This is the kind of guardrail that saves a system from accidental overexposure. The request may be valid. The user may even be authenticated. But the change still has to satisfy the policy encoded in the serializer.

Signals keep the request path thin

The repo uses Django signals to send email notifications when courses or enrollments change. That keeps the views clean. The trade-off is discoverability. You gain a thinner request path, but you have to trace the signal chain to see the full behavior.

That is a good fit here because notifications are side effects, not the core transaction. The request handler can stay focused on permission, persistence, and validation. The email logic lives where it belongs, after the model event fires.

There is a familiar Django trade-off here. Signals are elegant when you want decoupling. They are less elegant when you want all business logic to be visible in one place. This repo accepts that cost in exchange for cleaner views.

This is built like a deployable template

The project does not read like a classroom exercise. Docker support, PostgreSQL, Supabase guidance, OpenAPI docs, JWT auth, and tests in each app all point in the same direction: this is meant to be run, extended, and shipped.

CapabilityWhy it mattersWhat it signals about the repo
Docker and PostgreSQLFast local setup with a realistic databaseThe project expects real deployment constraints
Supabase supportCloud-friendly database configurationIt is not locked to a toy local stack
OpenAPI docsReadable API surface for consumersThe backend is meant for integration
Per-app testsConfidence around auth and policyThe architecture is designed to be maintained

That makes the repo useful as a pattern. You can imagine adding grades, attendance, or file uploads without rewriting the access model. The core idea is already stable: roles define what the system can see, edit, and emit.