Add new model and API endpoints¶
This guide aims to enable developers with little or no django experience to add django models and API endpoints to the project. Most code examples are followed by detailed explanations.
The developer will have exposure to the following in this document
- python
- django
- django rest framework
- relational database through the Django ORM (object-relational mapper)
- data types
- object-oriented concepts (object, inheritance, composition)
- unit testing
- API design
- command line
This guide assumes the developer has followed the working with issues guide and have forked and created a local branch to work on this. The development server would be already running in the background and will automatically apply the changes when we save the files.
We will choose the recurring_event issue as an example. Our goal is to create a database table and an API that a client can use to work with the data. The work is split into 3 testable components: the model, the admin site, and the API
Let's start!
Data model¶
TDD test
-
Write the test
We would like the model to store these data, and to return the name property in the str function.
In
app/core/tests/test_models.py
-
See it fail
-
Run it again after implementing the model to make sure the code satisfies the test
Add the model¶
Add the following to app/core/models.py
- We inherit all models from AbstractBaseModel, which provides a
uuid
primary key,created_at
, andupdated_at
timestamps. In the Github issue, these fields might be calledid
,created
, andupdated
. There's no need to add those. - Most fields should not be required. Text fields should be
blank=True
, data fields should benull=True
. - The data types in the github issue may be given in database column types such as
INTEGER
,VARCHAR
, but we need to convert them into Django field types when defining the model. VARCHAR
can be eitherCharField
orTextField
.CharField
has amax_length
, which makes it useful for finite length text data. We're going default to giving themmax_length=255
unless there's a better value likemax_length=2
for state abbreviation.TextField
doesn't have a maximum length, which makes it ideal for large text fields such asdescription
.
- Try to add the relationships to non-existent models, but comment them out. Another developer will complete them when they go to implement those models.
- Always override the
__str__
function to output something more meaningful than the default. It lets us do a quick test of the model by callingstr([model])
. It's also useful for the admin site model list view.
Run migrations¶
This generates the database migration files
Test
Since we overrode the __str__
function, we need to write a test for it.
-
Add a fixture for the model
Fixtures are reusable code that can be used in multiple tests by declaring them as parameters of the test case. In this example, we show both defining a fixture (recurring_event) and using another fixture (project).
Note: The conftest file is meant to hold shared test fixtures, among other things. The fixtures have directory scope.
Add the following to
app/core/tests/conftest.py
app/core/tests/conftest.py - We name the fixture after the model name (
recurring_event
). - This model makes use of the
project
model as a foreign key relation, so we pass in theproject
fixture, which creates aproject
model. - We create an object of the new model, passing in at least the required fields. In this case, we passed in enough arguments to use the
__str__
method in a test.
- We name the fixture after the model name (
-
Add a test case
When creating Django models, there's no need to test the CRUD functionality since Django itself is well-tested and we can expect it to generate the correct CRUD functionality. Feel free to write some tests for practice. What really needs testing are any custom code that's not part of Django. Sometimes we need to override the default Django behavior and that should be tested.
Here's a basic test to see that the model stores its name.
Add the following to
app/core/tests/test_models.py
app/core/tests/test_models.py - Pass in our fixture so that the model object is created for us.
- The
__str__
method should be tested since it's an override of the default Django method. - Write assertion(s) to check that what's passed into the model is what it contains. The simplest thing to check is the
__str__
method.
-
Run the test script to show it passing
Check and commit
This is a good place to pause, check, and commit progress.
-
Run pre-commit checks
-
Add and commit changes
Admin site¶
Django comes with an admin site interface that allows admin users to view and change the data in the models. It's essentially a database viewer.
Register the model¶
In app/core/admin.py
-
Import the new model
app/core/admin.py -
Register the model with the admin site
app/core/admin.py - We declare a ModelAdmin class so we can customize the fields that we expose to the admin interface.
- We use the register decorator to register the class with the admin site.
- list_display controls what's shown in the list view
- list_filter adds filter controls to declared fields (useful, but not shown in this example).
View the admin site¶
Check that everything's working and there are no issues, which should be the case unless there's custom input fields creating problems.
-
See the development setup guide section on "Build and run using Docker locally" for how to view the admin interface.
-
Example of a custom field (as opposed to the built-in ones)
- Having a misconfigured or buggy custom field could cause the admin site to crash and the developer will need to look at the debug message and resolve it.
Test
- Feel free to write tests for the admin. There's no example for it yet.
- The reason there's no tests is that the admin site is independent of the API functionality, and we're mainly interested in the API part.
- When the time comes that we depend on the admin interface, we will need to have tests for the needed functionalities.
Check and commit
This is a good place to pause, check, and commit progress.
-
Run pre-commit checks
-
Add and commit changes
API¶
There's several components to adding API endpoints: Model(already done), Serializer, View, and Route.
Add serializer¶
This is code that serializes objects into strings for the API endpoints, and deserializes strings into object when we receive data from the client.
In app/core/api/serializers.py
-
Import the new model
app/core/api/serializers.py -
Add a serializer class
- We inherit from ModelSerializer. It knows how to serialize/deserialize the Django built-in data fields so we don't have to write the code to do it.
- We do need to pass in the
model
, thefields
we want to expose through the API, and anyread_only_fields
. uuid
,created_at
, andupdated_at
are managed by automations and are always read-only.
-
Custom data fields may need extra code in the serializer
- This non-built-in model field provides a serializer so we just point to it.
-
Custom validators if we need them
We will need to write custom validators here if we want custom behavior, such as validating URL strings and limit them to the github user profile pattern using regular expression, for example.
Add viewset¶
Viewset defines the set of API endpoints for the model.
In app/core/api/views.py
-
Import the model
app/core/api/views.py -
Import the serializer
app/core/api/views.py -
Add the viewset and CRUD API endpoint descriptions
- We inherit from ModelViewSet, which provides a default view implementation of all 6 CRUD actions:
create
,retrieve
,partial_update
,update
,destroy
,list
. - We use the
extend_schema_view
decorator to attach the API doc strings to the viewset. They are usually defined as docstrings of the corresponding function definitions inside the viewset. Since we useModelViewSet
, there's nowhere to put the docstrings but above the viewset. - The minimum code we need with
ModelViewSet
are thequeryset
, and theserializer_class
. - Permissions
- For now use
permission_classes = [IsAuthenticated]
- It doesn't control permissions the way we want, but we will fix it later.
- For now use
- We inherit from ModelViewSet, which provides a default view implementation of all 6 CRUD actions:
Extended example: Query Params
This example shows how to add a filter params. It's done for the user model as a requirement from VRMS.
-
Here's a more complex API doc example (this example is using the User model's ViewSet)
- Define strings for all 6 actions:
create
,retrieve
,partial_update
,update
,destroy
,list
. - This one is fancy and provides examples of data to pass into the query params. It's probably more than we need right now.
- The examples array can hold multiple examples.
- Example ID string has to be unique but is not displayed.
summary
string appears as an option in the dropdown.description
is displayed in the example.
- The examples array can hold multiple examples.
- Define strings for all 6 actions:
-
Add any query params according to the requirements (this example is using the User model's ViewSet)
-
Notice the
queryset
property is now theget_queryset(()
function which returns the queryset.The
get_queryset()
function overrides the default and lets us filter the objects returned to the client if they pass in a query param. -
Start with all the model objects and filter them based on any available query params.
-
Register API endpoints¶
In app/core/api/urls.py
-
Import the viewset.
app/core/api/urls.py -
Register the viewset to the router
app/core/api/urls.py - Params
- First param is the URL prefix use in the API routes. It is, by convention, plural
- This would show up in the URL like this:
http://localhost:8000/api/v1/recuring-events/
andhttp://localhost:8000/api/v1/recuring-events/<uuid>
- This would show up in the URL like this:
- Second param is the viewset class which defines the API actions
basename
is the name used for generating the endpoint names, such as-list, -detail, etc. It's in the singular form. This is automatically generated if the viewset definition contains a queryset
attribute, but it's required if the viewset overrides that with theget_queryset
functionreverse("recurring-event-list")
would returnhttp://localhost:8000/api/v1/recuring-events/
- First param is the URL prefix use in the API routes. It is, by convention, plural
- Params
Test
For the CRUD operations, since we're using ModelViewSet
where all the actions are provided by rest_framework
and well-tested, it's not necessary to have test cases for them. But here's an example of one.
In app/core/tests/test_api.py
-
Import API URL
app/core/tests/test_api.py -
Add test case
- Given
- Pass in the necessary fixtures
- Construct the payload
- When
- Create the object
- Then
- Check that it's created via status code
- Maybe also check the data. A real test should check all the data, but we're kind of relying on django to have already tested this.
- Given
-
Run the test script to show it passing
Check and commit
This is a good place to pause, check, and commit progress.
-
Run pre-commit checks
-
Add and commit changes
Push the code and start a PR
Refer to the Issues page section on "Push to upstream origin" onward.