Backend Architecture
This page collects defining aspects of the OpenBook backend architecture. The intention is to help developers understand the most important technology and implementation choices.
Technology Choices
Layers and modules in the backend source code
The OpenBook Server is built with the following technology:
Python |
Programming language |
Poetry |
Package manager |
Django Web Framework |
Core server framework |
Django Allauth |
Authentication, user management, single sign-on |
Django REST Framework |
REST API for the frontend and external clients |
Django Channels + chanx |
WebSocket support |
Celery |
Background task queue |
Instructor + MPC Python SDK |
AI integration |
The idea is to keep the technical requirements lean to enable easy deployment in custom environments. Therefore, the choice of Django might be considered “conservative”, but in fact it contains all needed functionality, like HTTP request routing, server-side templates, and database access, in a single, stable, and well-maintained dependency.
As shown in the figure, these technologies form the technical foundation of the OpenBook backend. The implementation is organized as a Django project containing multiple Django apps, whereas the apps can be grouped into core apps that provide technical platform functionality and content apps that provide user-visible features.
Architectural Cornerstones
REST API and Single Page App
![digraph rest_api_layers {
graph [bgcolor=transparent, rankdir=TB, nodesep=0.35, ranksep=0.6];
node [shape=box, style="rounded,filled", fontname="Sans", fontsize=11, width=2.8, height=0.7, fixedsize=true];
model [label=<<B>Model</B><BR/>Stores domain data<BR/>and core rules>, fillcolor="#e3f2fd", color="#1e88e5"];
serializer [label=<<B>Serializer</B><BR/>Validates payloads<BR/>and shapes JSON>, fillcolor="#fff3e0", color="#fb8c00"];
filterset [label=<<B>FilterSet</B><BR/>Defines query params<BR/>and queryset filters>, fillcolor="#e8f5e9", color="#43a047"];
viewset [label=<<B>ViewSet</B><BR/>Exposes list/detail actions<BR/>and binds the other classes>, fillcolor="#fce4ec", color="#d81b60"];
model -> serializer [color="#1e88e5"];
model -> filterset [color="#1e88e5"];
model -> viewset [color="#1e88e5"];
serializer -> viewset [color="#fb8c00"];
filterset -> viewset [color="#43a047"];
}](../_images/graphviz-c112a04865d560442d84df86a28c3c09384a8543.png)
Class relationships for the REST API
Django applications traditionally use server-side rendering for the UI. This is because, when Django was invented, web applications in general used server-side rendering to dynamically create HTML. JavaScript was only used for visual effects and small UX enhancements. Nowadays, web apps often consist of a single page app running completely in-browser and a server backend providing data persistency and domain logic. OpenBook falls into this category, too. Because it allows us to provide a much more dynamic and interactive UI and the REST API feeding the UI can also be consumed by other clients or even 3rd-party systems.
Understanding RESTful principles is easy. Writing a good REST API isn’t. OpenBook uses Django REST Framework and a few related libraries to provide a flexible and modern REST API that not only serves data over HTTP. In particular:
Flexible response structure — Simpler REST APIs use fixed request and response structures, meaning that the full resource data is always exchanged between client and server. But:
What if the client only needs a small subset of fields from a much larger resource?
What if the client just wants to update a single field or two?
What if the resource has a deep structure with relations to other resources?
What if the client needs to query (also known as search or filter) the list of resources?
Clearly, one size doesn’t fit all. For this reason, OpenBook allows the clients to narrow down
which fields to read or write and to decide which relations to expand and which relations to
reduce to a single foreign key ID. This is made possible through the drf-flex-fields2
library (our own maintained fork of the great drf-flex-fields by Robert Singer).
Additionally, Django Filters is included to allow clients not only to list resources but also to
filter the result
list with queries.
For each model, the following three classes must be written, to pull this off:
Class |
Responsibility |
|---|---|
ViewSet |
Definition and handling of RESTful HTTP endpoints |
FilterSet |
Definition of query parameters and filtering the result list |
Serializer |
JSON (de)serialization, flexible response shaping |
OpenAPI Schema — For a long time, REST web services were missing a universally accepted machine-readable description like WSDL in the SOAP world. In recent times, OpenAPI — formerly known as Swagger — has filled this gap. This allows us to generate type-safe client stubs, validate requests and responses, and much more. Unlike in other projects, in OpenBook the OpenAPI schema is generated from the Python source, which means it will always be up to date.
User documentation — Another advantage of OpenAPI is that browsable API documentation can be generated from it. To this end, OpenBook integrates ReDoc to provide human-readable API documentation.
Enhanced Django Admin
![digraph admin_layers {
graph [bgcolor=transparent, rankdir=TB, nodesep=0.35, ranksep=0.6];
node [shape=box, style="rounded,filled", fontname="Sans", fontsize=11, width=2.8, height=0.7, fixedsize=true];
model [label=<<B>Model</B><BR/>Persistent domain object>, fillcolor="#e3f2fd", color="#1e88e5"];
resource [label=<<B>Resource</B><BR/>Maps rows for import<BR/>and export files>, fillcolor="#fff3e0", color="#fb8c00"];
admin [label=<<B>Admin</B><BR/>Defines list/change views,<BR/>inlines, tabs, and actions>, fillcolor="#e8f5e9", color="#43a047"];
model -> admin [color="#1e88e5"];
model -> resource [color="#1e88e5"];
resource -> admin [color="#fb8c00"];
}](../_images/graphviz-c13dd69e3e1b717f01629ad8a9e54a57423705f4.png)
Class relationships for Admin integration
The Admin is a very useful and rather unique feature to Django. With just a few lines of code any model can be integrated, supporting all CRUD (Create, Read, Update, Delete) operations. In OpenBook the Django Admin is used for developers and administrators to customize the system, inspect live data and create mock data even before the corresponding part in the frontend app has been built. For this reason, even though the primary end users (lecturers and students) never use the Admin, it is treated as a first-class citizen, providing an optimized user interface for each model. In particular:
Django Unfold is integrated to provide a modern user experience throughout.
Related models are grouped in sub-categories below each technical app.
Inline models and tab pages are used extensively to optimize data maintenance.
DjangoQL is integrated to provide additional search capabilities beyond traditional filters.
Django Import/Export is integrated to allow importing and exporting models from/to files.
Therefore, for each model, at least the following classes must be written:
Class |
Responsibility |
|---|---|
Admin |
Definition of the change and list views |
Resource |
File-based import and export of model data |
Role-Based Permissions
Another difference to regular Django apps lies in the permission handling. By default, Django uses coarse permissions that define what a user can do with a given type of data (model), e.g. “can view textbooks”. While perfectly sensible for applications like news pages or blog engines, this falls short in educational contexts. Because in education it is not enough to say, that “teachers can edit course contents” or that “teachers can assess tests”. One must also say which courses they can edit or which tests for which students they may assess.
Django provides object-level permission hooks for this, but ships no concrete implementation. Similar to most Learning Management Systems, OpenBook implements a role-based permission system, where users have different roles in different contexts. The prime example is being a teacher/lecturer on one course but only a student in another.
See also
See the next section for a deep-dive on how the permission system is implemented.
Database Models
![digraph model_mixins {
graph [bgcolor=transparent, rankdir=TB, nodesep=0.35, ranksep=0.6];
node [shape=box, style="rounded,filled", fontname="Sans", fontsize=11, width=2.8, height=0.7, fixedsize=true];
edge [arrowsize=1.1, arrowtail=empty, arrowhead=none];
uuid1 [label=<<B>UUIDMixin</B><BR/>UUID primary key>, fillcolor="#e3f2fd", color="#1e88e5"];
audit1 [label=<<B>CreatedModifiedByMixin</B><BR/>Creation and change audit trail>, fillcolor="#e3f2fd", color="#1e88e5"];
others1 [label=<<B>...Mixin</B><BR/>Additional mixins>, fillcolor="#e3f2fd", color="#1e88e5"];
model [label=<<B>Concrete Model</B><BR/>Business fields and relations>, fillcolor="#f5f5f5", color="#616161"];
uuid2 [label=<<B>UUIDMixin</B><BR/>UUID primary key>, fillcolor="#e3f2fd", color="#1e88e5"];
i18n2 [label=<<B>TranslatableMixin</B><BR/>Language-specific text handling>, fillcolor="#e3f2fd", color="#1e88e5"];
text [label=<<B>Text Model</B><BR/>Translated labels, titles,<BR/>and descriptions>, fillcolor="#f5f5f5", color="#616161"];
{ rank=same; uuid1; audit1; others1; }
{ rank=same; uuid2; i18n2; }
uuid1 -> model [dir=back];
audit1 -> model [dir=back];
others1 -> model [dir=back];
model -> uuid2 [style=invis, weight=10];
model -> i18n2 [style=invis, weight=10];
uuid2 -> text [dir=back];
i18n2 -> text [dir=back];
model -> text [label="parent / translations", arrowhead=normal];
}](../_images/graphviz-0edc788d08862135156b5f0f5c489c9e1f356c95.png)
Mixin inheritance for models
Most business models inherit a small set of abstract mixins instead of redefining technical
fields in each app. UUIDMixin replaces numeric IDs with UUIDs, CreatedModifiedByMixin
adds an audit trail, and further mixins such as ActiveInactiveMixin or the scope-related
mixins add recurring behavior only where needed.
Translatable content is modeled separately. A main model keeps the stable business identity and
relations, while a companion *Text model in the same file inherits TranslatableMixin and
stores the language-specific labels, titles, and descriptions for that parent object.
Hint
In OpenBook all self-defined models use UUIDMixin to replace the sequential ID
key field with stable UUIDs. This helps to avoid number clashes in migrations.
Source Code Structure
![digraph app_layout {
graph [bgcolor=transparent, rankdir=TB, nodesep=0.35, ranksep=0.6];
node [shape=box, style="rounded,filled", fontname="Sans", fontsize=11, width=2.8, height=0.5, fixedsize=true];
model [label=<<B>models/learning_goal.py</B><BR/>Django model>, fillcolor="#e3f2fd", color="#1e88e5"];
admin [label=<<B>admin/learning_goal.py</B><BR/>Admin integration>, fillcolor="#e8f5e9", color="#43a047"];
api [label=<<B>viewsets/learning_goal.py</B><BR/>REST API integration>, fillcolor="#fff3e0", color="#fb8c00"];
routes [label=<<B>routes.py</B><BR/>Registers API endpoints>, fillcolor="#fce4ec", color="#d81b60"];
model -> admin [color="#43a047"];
model -> api [color="#fb8c00"];
api -> routes [color="#d81b60"];
}](../_images/graphviz-4b50ecf62fa7fb1834d2e438cf27362aeb08c0dd.png)
Mirrored source structure for the model and all layers above
Typically, Django Apps define all models in a single source file called models.py, the
Admin views in a file called admin.py, the REST endpoints in a file called viewsets.py
and so on. In OpenBook these are directories with one source file per domain concept, e.g:
src/openbook/learning_progress/
├── __init__.py
├── models/
│ ├── __init__.py
│ └── badge.py
│ └── learning_goal.py
├── admin/
│ ├── __init__.py
│ ├── badge.py
│ └── learning_goal.py
└── viewsets/
├── __init__.py
├── badge.py
└── learning_goal.py
To make these directories behave like single source files, they contain a __init__.py file,
that re-exports all public contents of the other files. Additionally, as can be seen, each directory
contains the same set of files. This is because each model must be integrated with the Django Admin
as well as be exposed through the REST API.
To simplify these tasks a little and enforce uniform behavior, the OpenBook core and auth apps contain several mixins for recurring concepts. These serve as conceptual building blocks throughout the system that reduce variation and help developers understand new features by analogy with existing ones.
Permission Handling in Detail
This section describes permission handling in Django and how it is used in the application. It explains the implementation strategy to make regular model-based permissions (for example, “Can create textbook”) coexist with object-level permissions (for example, “Can change THIS textbook”).
Default Permissions for Each Model
When the app django.contrib.auth is installed, Django automatically creates four permission objects
in the database for each model:
Permission |
Meaning |
|---|---|
|
Add new objects |
|
Search and display objects |
|
Modify existing objects |
|
Delete existing objects |
{app_label} is the owning app’s label (as defined in the App class), and {model} is the name
of the model in lowercase (no other transformation done).
How Permissions Are Checked on Model Level
Django Admin – By default, model permissions are checked in the Django Admin (in the
ModelAdmin class, methods has_..._permission(self, request)()) to check whether a user is allowed
to perform the corresponding action.
Own Code – No permissions check is done when directly accessing the database with the Django ORM.
Permission checks must be performed in the higher levels (views), of which the Django Admin happens
to be one. The easiest way to check a permission is to call has_perm() on the user object, for
example user.has_perm("myapp.view_model")(). This, in turn, iterates over the installed
authentication backends and calls has_perm(user_obj, perm)() until one backend grants the
permission.
Django REST Framework – Django REST Framework does not do permission checks unless specifically
asked in the ViewSet. Permission checks are encapsulated in permission_classes that may
implement arbitrary logic. These classes inherit from a BasePermission that defines the method
has_permission(self, request, view)(). Most implementations do not use the django.contrib.auth
permissions but rather implement simpler checks like IsAuthenticated or AllowAny.
DjangoModelPermissions applies permission checks similar to Django Admin’s ModelAdmin, though
they do not share any code.
When a ViewSet contains multiple permission classes as a list, permission is only granted if all
of them return True:
class MyModelViewSet(ModelViewSet):
queryset = MyModel.objects.all()
serializer_class = MyModelSerializer
permission_classes = [IsAuthenticated, IsStaff, IsOwner]
However, permissions can also be combined using logic operators, for example:
class MyModelViewSet(ModelViewSet):
# ...
permission_classes = IsAuthenticated & (IsStaff | IsOwner)
How Object-Level Permissions Are Checked
Object-level permissions always require a custom authentication backend, as Django only includes the API. The API is a compatible extension to the regular API:
Extension Point |
API |
|---|---|
Django User |
|
Authentication Backend |
|
Django Admin |
|
Django REST Framework |
|
There are still framework-level limitations to keep in mind.
Queryset Visibility — Neither Django, Django Admin, nor Django REST Framework apply object permissions to querysets. This means users can always query objects and read all data even when they lack “view” permissions.
Default ModelBackend Behavior — Django’s default ModelBackend always returns False when
object-level permissions are checked, even when the user has the global permission.
ModelAdmin Object Parameter — ModelAdmin ignores the obj parameter and always checks
model permissions.
Create Permission Gap in DRF — Django REST Framework checks object-level permissions only in
class DjangoObjectPermissions, but despite the documentation, it seems the add permission is not
checked (because the new object does not yet exist).
Limitations Around get_object() — Django REST Framework checks object permissions in the
get_object() method. It may be necessary to manually call the inherited method when it is
replaced with a custom implementation or when the generic REST views are not used. Limitations of
object permissions in Django REST Framework
(Source):
For performance reasons, the generic views will not automatically apply object-level permissions to each instance in a queryset when returning a list of objects.
Often, when using object-level permissions, you will also want to filter the queryset appropriately to ensure that users only have visibility onto instances that they are permitted to view.
Because the
get_object()method is not called, object-level permissions from thehas_object_permission()method are not applied when creating objects. In order to restrict object creation, you need to implement the permission check either in theSerializerclass or overrideperform_create()in theViewSetclass.
How Our Custom Authentication Backend Is Implemented
Adding Roles to Models – Our permission system is built around the premise that some models
support object permissions and others do not. Models supporting object permissions implement the
ScopedRolesMixin to become a scope for user roles and related objects:
Concept |
Meaning |
|---|---|
Roles |
Exist within the scope and collect Django |
Allowed Permissions |
Define which permissions can be added to the roles of a scope. |
Role Assignments |
Assign roles to users within the scope. |
Access Requests |
Can be created by users to request a role within a scope. |
Enrollment Methods |
Allow users to self-enroll to get a role within a scope. |
Public Permissions |
Are permissions for non-enrolled and anonymous users within a scope. |
Typical scopes are courses and textbooks. They often contain related objects (for example, course
materials or textbook pages) that also support object permissions but share the scope of their parent
object. This allows permissions like “In this course (scope object), teachers (role) can create
materials (related object)” to be expressed. For this, the related objects must inherit
RoleBasedObjectPermissionsMixin and override the get_scope() method. In both cases (scope
objects and related objects), the method has_obj_perm() can be overridden to implement additional
custom checks.
Authentication Backend – We provide a custom authentication backend in class
openbook.auth.backends.RoleBasedObjectPermissionsBackend that extends the stock Django
ModelBackend. This is simpler than reusing third-party libraries like Django Guardian,
which requires explicitly persisting and keeping in sync who can do what for every single object.
RoleBasedObjectPermissionsBackend changes permission resolution as follows:
The user is a superuser.
A matching global entry exists in
AnonymousPermission.The user and the object are the same.
The user is the object’s
owner(optional).The object’s
has_obj_perm()method (via mixins, optional).Public permissions of the scope.
Roles assigned to the user.
Regular non-object permissions from user and group assignments.
If
viewfails, retry aschange(including object-aware checks).
This keeps object-level behavior consistent while preserving Django’s model-level permission model.
Because this backend inherits from ModelBackend, the default backend does not need to be added
separately in settings.
Unlike Django’s ModelBackend, the Django Admin first checks “view” then “change” permissions when
an object should be displayed. This logic is mirrored in our RoleBasedObjectPermissionsBackend to
unify the behavior in all parts of the application.
How We Iron Out the Inconsistencies
Querying and Filtering – For REST APIs, querysets are filtered with
openbook.drf.filters.DjangoObjectPermissionsFilter. This filter evaluates
request.user.has_perm("{app_label}.view_{model}", obj)() for each object and returns only visible
records. This closes DRF’s default queryset visibility gap for list endpoints.
Django Admin – The active admin base class is openbook.admin.CustomModelAdmin. Admin model
classes inherit from this shared base, while permission decisions still flow through
Django Admin – The code contains openbook.core.admin.utils.model.ModelAdmin as a replacement
for the stock ModelAdmin to make sure that object-level permissions are checked. But we currently
do not use it. If we want to use it in the future, the inheritance chain of
openbook.admin.CustomModelAdmin must be adapted.
Django REST Framework – In settings.py, the default permissions are
IsAuthenticated and DjangoObjectPermissionsOnly. In practice, this means all endpoints are
protected by default unless a view explicitly overrides permission_classes (for example with
AllowAny).
The module openbook.drf adds focused building blocks around DRF:
openbook.drf.permissions.DjangoObjectPermissionsOnlycustomizes DRF object-permission behavior, includesviewin the permission map, and keeps model checks compatible with the backend logic.openbook.drf.flex_serializers.FlexFieldsModelSerializercallsfull_clean()before saving to reuse model-level validation.openbook.drf.viewsets.ModelViewSetMixinperforms object permission checks duringPOSTby constructing a pre-filled instance beforeperform_create().
DjangoObjectPermissionsOnly is a specialized version of DjangoObjectPermissions that includes
view checks and delegates the final model-versus-object fallback behavior to our authentication
backend.