Post

Creating an RPG API in Python Django (Part 4) - Jobs, Stats, and Foreign Keys

In our last post, we setup our experience points system, allowing our characters to level based on the number of experience points they’ve gained. This week, we’ll begin creating the various stats that will effect their abilities in combat and tie those stats to specific jobs. This means we will need to define a job model and link it back to each character via a foreign key, and then learn how to represent that relationship within our serializers and display it within our existing character endpoint.

If you’d like to follow along, you can find the complete source code from last week on GitHub. Clone down the repository and follow the setup instructions in the readme.

We’re going to be moving a little more quickly this week than we have in past weeks. Now that we have the basics of model and serializer creation down, the initial steps to getting our jobs setup should be fairly quick. We’ll take our time as we explore connecting the two models, but won’t dwell on the creation of the new model itself. Because we’ll be moving a little faster, I think it will be helpful to list out our goals for this week:

  1. Create a Job model that defines stat growth for each character depending on which Job they have
  2. Connect the Job model to our Character model and explore different ways to represent the relationship through our serializer
  3. Create properties or fields (we’ll explore the decision of which to use again) that represent our stats

Defining Our Stats

Before we can build our model, we should define what we’re building clearly. I want to build a system where each of 6 basic stats are influenced by the current Job of a character. In addition, each character will have 6 additional stats that are effected by the 6 base stats and are additionally affected by equipped items, which we’ll build out in a future post.

These stats could be anything and could represent anything you want. I will be using the following 6 stats which are based on 6 of the 7 stats from Final Fantasy XI.

Stat NameEffect
StrengthIncreases physical attack of both melee and ranged attacks
DexterityIncreases melee accuracy and critical hit rate
VitalityIncreases defense and effects total hit points
AgilityIncreases evasion, ranged accuracy and character’s speed
IntelligenceIncreases magic accuracy, black magic potency and effects total magic points
MindIncreases magic accuracy, white magic potency and effects total magic points

In addition to these 6 base stats, we’ll be using 6 stats that directly play into our battle calculations. These stats are effected by the base stats above and are modified by equipped items. For example, weapons will add to our attack stat and pieces of armor will add to our defense stat. Lighter armor might add evasion and speed, where heavy armor might reduce those stats. Magical weapons and armor might effect the magic stat. If we were building out a complete battle system, we might focus on these calculated stats when it comes to status effects, both beneficial and enfeebling.

Stat NameEffect
AttackDetermines the amount of physical damage dealt by melee and ranged weapons
DefenseDetermines the amount of physical damage taken by melee and ranged weapons
MagicDetermines the amount of magical damage dealt and taken by spells
AccuracyDetermines the likely hood of landing a melee attack or ability
EvasionDetermines the likely hood of a melee attack missing
SpeedDetermines the order in which characters take their turns

This should give us a nice base of stats to work with as an example. While I’ve not entirely thought through how the system would work in reality, this should give us plenty of levers to pull when designing our Jobs to allow for some variety between them. Since the purpose of this project isn’t actually to design a working RPG combat system, the specific details aren’t really important. However, it’s always more fun to work on projects that have a little practical thought behind their design so that you force yourself to work through real-world problems that might arise when working on them.

Designing Our Model

Our Job model will be fairly simple to begin with. We’ll want each job to have a name, a description and then some way of effecting the the values of our six base stats. For simplicity sake, we’ll be using a linear function for stat growth instead of an exponential function like we did with experience points. However, whatever formula you use, you will likely want a certain level of precision, so we’ll use a FloatField to store a constant that will effect how quickly or slowly a particular stat grows for that job. With those basic parameters worked out, you can add the following model to you characters/models.py file.

1
2
3
4
5
6
7
8
9
10
11
12
class Job(models.Model):
    name = models.CharField(max_length=255, null=False, blank=False)
    description = models.TextField(null=True, blank=True)
    strength_mod = models.FloatField(null=False, blank=False)
    dexterity_mod = models.FloatField(null=False, blank=False)
    vitality_mod = models.FloatField(null=False, blank=False)
    agility_mod = models.FloatField(null=False, blank=False)
    intelligence_mod = models.FloatField(null=False, blank=False)
    mind_mod = models.FloatField(null=False, blank=False)

    def __str__(self):
        return self.name

This will provide the basic structure of our model and is a great starting point. Since the Character class will be referencing this class in a few moments, you will want to make sure you create the Job class before the Character class in your code. Let’s go ahead and add a field to our Character class that identifies which job the character has. Since a character can only have one job at a time, but multiple characters could have the same job, we’ll define this as a many-to-one relationship. To do this, we need the ForeignKey to be on the Character model/table.

1
2
3
4
5
6
class Character(models.Model):
    name = models.CharField(max_length=255, null=False, blank=False)
    description = models.TextField(null=True, blank=True)
    experience_points = models.IntegerField(null=False, blank=False, default=0)
    level = models.IntegerField(null=False, blank=False, default=1, editable=False)
    job = models.ForeignKey(Job, on_delete=models.CASCADE)

Foreign Key Constraints

Let’s go ahead and try to create our migration for these changes and apply them using the command python manage.py makemigrations && python manage.py migrate. A Foreign Key cannot be null by default, so just like some of our other fields we’ve added, we’ll need to provide a default value as part of our migration. However, we don’t currently have any jobs that we’ve created that we can use as a default. This poses an interesting - and frustratingly common - dilemma that we need to work through in order to get this new field into our database.

There are a number of ways to tackle adding a new field like this. Some developers opt to make the foreign key nullable for the initial migration and then make it not nullable in a future migration. This is a valid approach, however, it makes certain assumptions about the data within the application that are not well defined within the application itself. Say, for example, you create a new foreign key field that links to the class Widget and set it to be nullable. You then get that code into production and your users create a few new Widget objects, one of which works nicely as a standard default and has a primary key of 3. So in your next sprint, you make your field not nullable, and set the default - either in the model or in the migration - to the value 3, which in your production system points to the Widget you want to use as your default.

Some of you may already see the problem with this. If someone else pulls down your code and runs the migrations against their brand new database, there will be no Widget with an ID of 3. The migration will fail, since the foreign key constraint can’t be enforced if the record doesn’t exist. One interesting way to approach the problem - and the one we’ll be using here - is to use the migration itself to ensure a single default object always exists and always has a particular ID.

Custom Migrations

To implement this work around, we’re going to design a custom migration. There are several of ways to do this, and we will be going over the two I find most useful. The first is to create a completely blank migration where you can then provide all the migration logic yourself. To do this, you would use the following command: python manage.py makemigrations <app_name> --empty. When creating an empty migration, it’s important to provide the app name if you have multiple apps in your project. Django can’t infer where to create the migration file if you don’t tell it which app it’s for. Once you create the migration, you’ll have a new file in the migrations directory for your application which will contain the minimum required syntax for a migration.

1
2
3
4
5
6
7
8
9
10
11
from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('characters', '0002_some_previous_migration'),
    ]

    operations = [
    ]

If you tried out this command on your local instance, just be sure to delete the migration file before moving onto any of the further steps so that you’re not left with an empty migration in your project.

Once you have this file you can begin adding operations to the operations array for each change you would like made. Going over the specifics of migration operations is a little out of the scope of what we’re doing here, but know that if you need to do something completely from scratch, this is a good starting place.

To achieve our goal of creating a default value within our migration, we’re going to let Django generate the schema modification portions of the migrations and then add our own custom functions for creating and deleting a default value.

Since migrations can be rolled back, it’s important to always create a reverse operation for a custom action if that action needs to be rolled back with the schema.

First, have Django generate a new migration based on the model changes, but do not apply the migration yet: python manage.py makemigrations characters. This should create a new migration file that looks something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from django.db import migrations, models
import django.db.models.deletion

class Migration(migrations.Migration):
    dependencies = [
        ('characters', '0002_character_experience_points_character_level'),
    ]

    operations = [
        migrations.CreateModel(
            name='Job',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('description', models.TextField(blank=True, null=True)),
                ('strength_mod', models.FloatField()),
                ('dexterity_mod', models.FloatField()),
                ('vitality_mod', models.FloatField()),
                ('agility_mod', models.FloatField()),
                ('intelligence_mod', models.FloatField()),
                ('mind_mod', models.FloatField()),
            ],
        ),
        migrations.AddField(
            model_name='character',
            name='job',
            field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='characters.job'),
            preserve_default=False,
        ),
    ]

If you’ve been looking over the other migrations we’ve generated, this should look pretty standard. The foreign key field type has a few extra bits that it would be good to read up on, but is other-wise pretty standard. To this migration, we want to add two functions. The first function will add a new Job object with an ID of 1 to our database and the second function will remove it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.db import migrations, models
import django.db.models.deletion

def create_default_job(apps, schema_editor):
    Job = apps.get_model('characters', 'Job')
    Job(
        id=1,
        name='Freelancer',
        description='Default job for all new characters',
        strength_mod=1.0,
        dexterity_mod=1.0,
        vitality_mod=1.0,
        agility_mod=1.0,
        intelligence_mod=1.0,
        mind_mod=1.0
    ).save()

def remove_default_job(apps, schema_editor):
    Job = apps.get_model('characters', 'Job')
    try:
        Job.objects.get(pk=1).delete()
    except Job.DoesNotExist:
        print("Default job did not exist so was not deleted.")

class Migration(migrations.Migration):
    ...

Because certain classes may or may not exist when a migration is run, it’s important to refer to models through the apps object that’s passed to migration functions. More can be read about both the apps and schema_editor objects in the documentation for the RunPython class which will be calling these functions. Once we have a reference to the Job model we can create the default object using the normal initialization method we would use elsewhere and save it. To remove the object, we go ahead and use a chained get().delete() wrapped in a try/except just to make sure it doesn’t exception out if the object doesn’t exist.

To hook these operations into the migration, we’ll want to use the RunPython function provided by the migrations module. We’ll insert a call to RunPython right in between our new model creation and the modification of the Character object to include the new ForeignKey.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from django.db import migrations, models
import django.db.models.deletion

def create_default_job(apps, schema_editor):
    Job = apps.get_model('characters', 'Job')
    Job(
        id=1,
        name='Freelancer',
        description='Default job for all new characters',
        strength_mod=1.0,
        dexterity_mod=1.0,
        vitality_mod=1.0,
        agility_mod=1.0,
        intelligence_mod=1.0,
        mind_mod=1.0
    ).save()

def remove_default_job(apps, schema_editor):
    Job = apps.get_model('characters', 'Job')
    try:
        Job.objects.get(pk=1).delete()
    except Job.DoesNotExist:
        print("Default job did not exist so was not deleted.")


class Migration(migrations.Migration):

    dependencies = [
        ('characters', '0002_character_experience_points_character_level'),
    ]

    operations = [
        migrations.CreateModel(
            name='Job',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('name', models.CharField(max_length=255)),
                ('description', models.TextField(blank=True, null=True)),
                ('strength_mod', models.FloatField()),
                ('dexterity_mod', models.FloatField()),
                ('vitality_mod', models.FloatField()),
                ('agility_mod', models.FloatField()),
                ('intelligence_mod', models.FloatField()),
                ('mind_mod', models.FloatField()),
            ],
        ),
        migrations.RunPython(
            create_default_job,
            remove_default_job
        ),
        migrations.AddField(
            model_name='character',
            name='job',
            field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='characters.job'),
            preserve_default=False,
        ),
    ]

And that’s our complete migration code. If you run the migration, the Job table will get created, followed the creation of the default job and then an alter statement will be run against the Character table adding the foreign key. If you rollback the migration - python manage.py migrate characters 0002 - the reverse of each operation will run in reverse order. So the foreign key and column will be removed, our remove_default_job function will then run followed by the deletion of the Job table.

Choices

We could leave our Job model like this and it would be perfectly functional. However, there’s still the detail of what our stat growth formula is going to be and how we’re going to control different levels of growth. One common model that is used in RPGs is to standardize an array of growth levels that in turn correspond to some constant that’s used in the growth formula. So for example, you might see something that looks like the following:

Growth RateStarting Amount (Lvl 1)Ending Amount (Lvl 99)
A10100
B990
C775
D560
E445
F230

This example is probably not very practical, but hopefully it conveys the idea. Instead of having to come up with specific constants for growth for each stat for each job, we can instead create an array of constants with a nice human readable name (in this case a “grade” of sorts) and then assign each job a “growth rate” for each stat. Warriors might get an A in strength and vitality, but an F in intelligence. Mages on the other hand might have As and Bs for intelligence and mind, but lower rates of growth for physical stats.

In Django, we can easily map a set of human-readable choices to a desired data type using the choices parameter available in most field types. You need to define an array of tuples, which each tuple containing the stored value and then the display value of each choice, and then pass that array to the choices parameter. Alternatively, you can create an enumeration and pass it to the choices field. In this case, we’re going to use a custom Django type that derives from the models.Choices class that will allow us to use the values like their an enumeration but correctly translates them to the appropriate type when added into the choices field.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Job(models.Model):

    class GrowthRate(float, models.Choices):
        A = 2.0, 'A'
        B = 1.8, 'B'
        C = 1.6, 'C'
        D = 1.4, 'D'
        E = 1.2, 'E'
        F = 1.0, 'F'

    name = models.CharField(max_length=255, null=False, blank=False)
    description = models.TextField(null=True, blank=True)
    strength_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)
    dexterity_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)
    vitality_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)
    agility_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)
    intelligence_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)
    mind_mod = models.FloatField(null=False, blank=False, choices=GrowthRate.choices)

    def __str__(self):
        return self.name

Because Django does not offer a FloatChoices field, we need to construct one by subclassing our GrowthRate class to both float and models.Choices. We are then able to pass the property .choices to the choices parameter on each FloatField in our Job model. This change does require creating a new migration, although nothing on the database will actually change. However, not creating the migration will cause Django to constantly throw a warning as you run things, so I generally go ahead and create the migration just to quiet the warning.

Serializers

We now have our new model and we have our foreign key pointing to our new model. Let’s open up one of our API endpoints and see what everything looks like!

1
2
3
4
5
6
7
8
9
10
11

[
    {
        "id": 1,
        "name": "Bilbo",
        "description": "A very decent hobbit, indeed!",
        "experience_points": 50,
        "level": 2,
        "job": 1
    }
]

Okay, well… That was a little underwhelming. Let’s do a few things to give us some options here. We can, first, create a serializer for our Job model and create some endpoints for it.

Since we’ve covered most of these steps in past posts, I will not be explaining each new block of code going forward unless we’re doing something new in one of them.

1
2
3
4
5
6
from .models import Character, Job

class JobSerializer(serializers.ModelSerializer):
    class Meta:
        model = Job
        fields = '__all__'

Add the Job class to your imports and create your new serializer.

1
2
3
4
5
6
7
8
9
10
11
12
from .models import Character, Job
from .serializers import CharacterSerializer, JobSerializer

# Create your views here.
class JobListView(generics.ListAPIView):
    queryset = Job.objects.all()
    serializer_class = JobSerializer

class JobDetailView(generics.RetrieveAPIView):
    queryset = Job.objects.all()
    lookup_field = 'id'
    serializer_class = JobSerializer

Add the Job and JobSerializer classes to your imports and create two new read-only views.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from django.urls import path

from .views import (
    CharacterListView,
    CharacterDetailView,
    JobListView,
    JobDetailView
)

urlpatterns = [
    path(r'jobs/<id>/',
        JobDetailView.as_view(),
        name='api.characters.jobs.detail'
    ),
    path(r'jobs/',
        JobListView.as_view(),
        name='api.characters.jobs.list'
    ),
    path(r'<id>/',
        CharacterDetailView.as_view(),
        name='api.characters.detail'
    ),
    path(r'',
        CharacterListView.as_view(),
        name='api.characters.list'
    ),
]

Import your new views, add and rearrange the paths to provide for all the new endpoints. Because we’re catching a lot of URLs greedily with <id>/, we need to put it below the two URLs for jobs.

Once all of this is added, you should be able to browse to /api/v1/character/jobs/ and specifically /api/v1/character/jobs/1/ and see at least the default Freelancer job. I encourage you to go ahead and add a few additional jobs to your dataset so you have some data to play around with.

If you’re interested in which Jobs I created, you can click here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
[
    {
        "id": 1,
        "name": "Freelancer",
        "description": "Default job for all new characters",
        "strength_mod": 1.0,
        "dexterity_mod": 1.0,
        "vitality_mod": 1.0,
        "agility_mod": 1.0,
        "intelligence_mod": 1.0,
        "mind_mod": 1.0
    },
    {
        "id": 2,
        "name": "Warrior",
        "description": "Hits things",
        "strength_mod": 2.0,
        "dexterity_mod": 1.8,
        "vitality_mod": 1.8,
        "agility_mod": 1.6,
        "intelligence_mod": 1.4,
        "mind_mod": 1.4
    },
    {
        "id": 3,
        "name": "Monk",
        "description": "Hits things with fists",
        "strength_mod": 1.8,
        "dexterity_mod": 1.8,
        "vitality_mod": 2.0,
        "agility_mod": 1.8,
        "intelligence_mod": 1.2,
        "mind_mod": 1.6
    },
    {
        "id": 4,
        "name": "Thief",
        "description": "Steals things",
        "strength_mod": 1.6,
        "dexterity_mod": 1.8,
        "vitality_mod": 1.6,
        "agility_mod": 2.0,
        "intelligence_mod": 1.6,
        "mind_mod": 1.4
    },
    {
        "id": 5,
        "name": "Red Mage",
        "description": "Meh",
        "strength_mod": 1.6,
        "dexterity_mod": 1.6,
        "vitality_mod": 1.6,
        "agility_mod": 1.6,
        "intelligence_mod": 1.6,
        "mind_mod": 1.6
    },
    {
        "id": 6,
        "name": "Black Mage",
        "description": "Glass cannon",
        "strength_mod": 1.4,
        "dexterity_mod": 1.4,
        "vitality_mod": 1.4,
        "agility_mod": 1.6,
        "intelligence_mod": 2.0,
        "mind_mod": 1.8
    },
    {
        "id": 7,
        "name": "White Mage",
        "description": "Heals all the things",
        "strength_mod": 1.4,
        "dexterity_mod": 1.4,
        "vitality_mod": 1.6,
        "agility_mod": 1.4,
        "intelligence_mod": 1.8,
        "mind_mod": 2.0
    }
]

Updating Character Serializer

Now let’s go back to our character serializer and explore our options. We could leave things just the way they are. Developers using our API would need to get the ID of the job from our job field and pass it to the /api/v1/characters/jobs/<id> endpoint to get the details. But we have some other options we can provide.

First, we could provide a complete copy of each job for each character. To do this, we’d simply reuse the serializer we’ve just created for the Job model and add it to our Character serializer like so:

1
2
3
4
5
6
class CharacterSerializer(serializers.ModelSerializer):
    job = JobSerializer(many=False)

    class Meta:
        model = Character
        fields = '__all__'

Now when we refresh one of our endpoints using the Character serializer, we should see something that looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
    "id": 1,
    "job": {
        "id": 1,
        "name": "Freelancer",
        "description": "Default job for all new characters",
        "strength_mod": 1.0,
        "dexterity_mod": 1.0,
        "vitality_mod": 1.0,
        "agility_mod": 1.0,
        "intelligence_mod": 1.0,
        "mind_mod": 1.0
    },
    "name": "Bilbo",
    "description": "A very determined hobbit!",
    "experience_points": 50,
    "level": 2
}

We’ve been able to add quite a bit of detail here. If we were building a system where there were a lot of “jobs” and a lot of variety - meaning usually only a few characters might have the same job - then it might make a lot of sense to display the data like this. Developers on the frontend are very likely going to have to retrieve the job data anyway to make sure of the character data, and the data doesn’t repeat very often, so we’re not unnecessarily bloating our responses with redundant data.

In this case, however, we likely have a very small number of jobs and a lot of overlap between players having the same jobs. Instead of using our JobSerializer, we can use a simpler serializer that will provide less data, while still giving us what we need to display basic information without having to make another API call.

There are a variety of options that Django Rest Framework provides for representing related fields (i.e. ForeignKey fields). We’ll explore two of the most commonly used related field serializers and then will take a look at creating a simple related field of our own.

The first field we can try out is the String Related Field. This will give us a string representation of the object (in this case the job name), but no other information. To implement it, we can swap out the JobSerializer with the StringRelatedField.

1
2
3
4
5
6
class CharacterSerializer(serializers.ModelSerializer):
    job = serializers.StringRelatedField(many=False)

    class Meta:
        model = Character
        fields = '__all__'

This results in a nice simple representation of the job in our character detail view:

1
2
3
4
5
6
7
8
{
    "id": 1,
    "job": "Freelancer",
    "name": "Bilbo",
    "description": "A very determined hobbit!",
    "experience_points": 50,
    "level": 2
}

However, we currently have no way of retrieving additional information about the job using the job’s name. We could change this by adding a view that takes in the name as a parameter and returns back the job’s details, but as a job could - and in my example data does - have things like spaces or other characters that are not standard to URLs, this is usually not the way APIs are designed.

Alternatively, we could link out to our JobDetail view using a HyperlinkedRelatedField. In this case, none of the information related to the job would be accessible directly in the character view, but we’d have the exact API endpoint we need to reach in order to get the information.

1
2
3
4
5
6
7
8
9
10
11
class CharacterSerializer(serializers.ModelSerializer):
    job = serializers.HyperlinkedRelatedField(
        view_name='api.characters.jobs.detail',
        lookup_field='id',
        read_only=True,
        many=False
    )

    class Meta:
        model = Character
        fields = '__all__'

This results in our Character endpoint looking something like this:

1
2
3
4
5
6
7
{
    "id": 1,
    "job": "http://127.0.0.1:8000/api/v1/characters/jobs/1/",
    "description": "A very determined hobbit!",
    "experience_points": 50,
    "level": 2
}

This gives us all the information we need to retrieve the job information, but doesn’t give us immediate access to anything in particular that we might want to display. For example, we might not need all the job data when displaying a list of characters, but we might want to at least know what the name of the character’s current job is. We can solve this by creating a simpler custom serializer and assigning it to our Character serializer.

Custom Serializer

This will work just like the JobSerializer we created earlier, just with fewer fields defined. We want to provide as much information as is needed and link out to everything else. In this case, we’ll create a serializer that provides the ID, the name and a URL to the detail view.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from rest_framework import serializers
from rest_framework.reverse import reverse

from .models import Character, Job

class SimpleJobSerializer(serializers.ModelSerializer):
    detail_view = serializers.SerializerMethodField()

    class Meta:
        model = Job
        fields = [
            'id',
            'name',
            'detail_view'
        ]

    def get_detail_view(self, obj):
        return reverse(
            'api.characters.jobs.detail',
            kwargs={'id': obj.id},
            request=self.context['request']
        )

class JobSerializer(serializers.ModelSerializer):
    class Meta:
        model = Job
        fields = '__all__'

class CharacterSerializer(serializers.ModelSerializer):
    job = SimpleJobSerializer(many=False)

    class Meta:
        model = Character
        fields = '__all__'

In our simple serializer (which looks a lot more complicated than it really is), we configure the class to use the Job model as our model and define three fields: id, name and detail_view. The first two fields are part of our model, so there’s nothing to do for those. However, the detail_view is not a defined field, so we need to define it. We do so on the very first line after the class declaration, defining it as a SerializerMethodField. This is a special kind of field in Django Rest Framework that allows us to define through a function what data should be returned for that field. The function has to be named get_{field_name} and is read-only. In the code above, we define the get_detail_view function and pass in the obj as a parameter. This is the current Job being represented. Since we want the URL of the specific job, we can use the reverse function to generate the URL and return it back.

Note that I am using the Django Rest Framework version of reverse in the example above and not the function found in the django.urls module. When you pass the current request to the function - which is found in the rest_framework.reverse module - it will prepend on the schema and domain to provide you with an absolute URL instead of a relative.

If we refresh our Character detail view now, we should see something like the following:

1
2
3
4
5
6
7
8
9
10
11
12
{
    "id": 1,
    "job": {
        "id": 1,
        "name": "Freelancer",
        "detail_view": "http://127.0.0.1:8000/api/v1/characters/jobs/1/"
    },
    "name": "Bilbo",
    "description": "A very determined hobbit!",
    "experience_points": 50,
    "level": 2
}

This is a nice compromise between having too much data and not enough. Because we’ve provided the ID, name and detail view URL, we now have options. For example, on a search or simple list, we can just display the name and not use the additional data tied to the job. On a view where we might want additional job information, we can retrieve it once and cache it using the ID of the job, avoiding having to retrieve it again when it shows up in the data after the first time.

Final Thoughts

At this point, we’ve pretty well explored the basics of working with foreign keys within a Django project. There are more specifics when it comes to working with ManyToMany fields or when foreign keys are writable within views and serializers, but in most cases where the detail view is writable and it’s related representations are not (e.g. while a character’s job can be changed, details about the job cannot be changed from the character views), most of the work revolves around finding ways to represent the data in ways that are efficient while still being useful.

In our next post, we’ll incorporate our new Job data into the Character class and build out our stats. We’ll then finish off the series by creating equippable items - which will give us a chance to play with data validation - and then look at writing some tests for the project. See you next week!

This post is licensed under CC BY 4.0 by the author.