Django - adding gamification features

339 views Asked by At

I have a medium size Django REST app that I'm looking to add gamification features to.

The application in question is a school webapp where students can create mockup quizzes, participate in exams that teachers publish, and write didactical content that gets voted by other students and teachers.

I want to add some gamification features to make the app more interesting and to incentivize participation and usage of the various features: for example, each student will have a personal "reputation" score, and gain points upon completing certain actions--a student may gain points when completing a quiz with a high score, when submitting some content, or when receiving upvotes to such content.

The tricky part is I want to be able to have this logic be as separate as possible from the existing codebase, for various reasons: separation of concerns, ability to plug the engine in/out if needed, ability to easily deactivate features for certain groups of users, etc.

What I'm looking for here is some software engineering advice that's also Django-specific. Here's a high level description of what I'm thinking of doing--I'd like some advice on the approach.

  • create a new gamification app. Here I will have models that describe a change in reputation for a user and possibly other related events. The app should also send notifications when gamification-related events occur
  • from the gamification app, expose a callback-based interface, which the other primary app can call into to dispatch events
  • use the django-lifecycle package to call the callbacks from gamification when triggers occur.

This way, my existing models would only get touched to register the triggers from django-lifecycle (similar to signals). For example, let's say I want to give students points when they turn in an assignment. Let's say I have an AssignmentSubmission model to handle assignment submissions. With the added lifecycle hook, it'd look like this:

class AssignmentSubmission(models.Model):
    NOT_TURNED_IN = 0
    TURNED_IN = 1
    STATES = ((NOT_TURNED_IN, 'NOT_TURNED_IN'), (TURNED_IN, 'TURNED_IN'))

    user = models.ForeignKey(user)
    assignment = models.ForeignKey(assignment)
    state = models.PositiveSmallIntegerField(choices=STATES, default=NOT_TURNED_IN)

    @hook(AFTER_UPDATE, when="state", was=NOT_TURNED_IN, is_now=TURNED_IN)
     def on_turn_in(self):
        get_gamification_interface().on_assignment_turn_in(self.user)

The on_assignment_turn_in method might look something like:

def on_assignment_turn_in(user):
    ReputationIncrease.objects.create(user, points=50)
    notifications.notify(user, "You gained 50 points")

This is pretty much just a sketch to give an idea.

I am unsure how get_gamification_interface() would work. Should it return a singleton? Maybe instantiate an object? Or return a class with static methods? I think it'd be best to have a getter like this as opposed to manually importing methods from the gamification app, but maybe it could also create too much overhead.

What's a good way to handle adding "pluggable" features to a project that are inherently entangled with existing models and business logic while also touching those as little as possible?

2

There are 2 answers

0
Lorenzo Prodon On

Your idea was good. In the gamification app add your views, protect it with LoginRequiredMixin, and extend it with a check if a related record in the AssignmentSubmission table exists and is turned on.

In this way you have a 100% separated gamification views, models, logic, ecc...

In the existing views you can add a link to a gamification view.

3
Victor Donoso On

The foreign key approach is fine. You can easily chain and link queries using information from existing tables and you could even avoid touching the original code by importing your models to the new app. You can use Django signals in your new app and ditch the django-lifecycle extension to avoid adding lines to your core models. I used the following approach to keep track of modified records in a table; take a TrackedModel with fields field_one, field_two, field_n... which will be tracked by one of your new app's model, namely RecordTrackingModel:

from parent_app.models import TrackedModel # The model you want to track from a parent app.
from django.db.models.signals import post_save # I'm choosing post_save just to illustrate.
from django.dispatch import receiver
from datetime import datetime

class RecordTrackingModel(models.Model):
    record = models.ForeignKey(TrackedModel, verbose_name=("Tracked Model"), on_delete=models.CASCADE)
    field_one = models.TextField(verbose_name=("Tracked Field One"), null=True, blank=True) # Use same field type as original
    field_two = models.TextField(("Tracked Field Two"))
    field_n = ...
    notes = models.TextField(verbose_name=("notes"), null=True, blank=True)
    created = models.DateTimeField(verbose_name=("Record creation date"), auto_now=False, auto_now_add=True)

@receiver(post_save, sender=TrackedModel) # Here, listen for the save signal coming from a saved or updated TrackedModel record.
def modified_records(instance, **kwargs):
    record = instance
    tracked_field_one = instance.field_one
    tracked_field_two = instance.field_two
    tracked_field_n = another_function(instance.field_n) #an external function that could take a field content as input.
    ...
    note = 'Timestamp: ' + str(datetime.now().isoformat(timespec='seconds'))
    track_record = RecordTrackingModel.objects.create(record=record, field_one=tracked_field_one, field_two=tracked_field_two, field_n=tracked_field_n, ..., notes=note)
    return track_record

There's no need to add functions to your pre-existing models as the signal dispatcher triggers whenever a save or delete signal appears at TrackedModel. Then you could place "if" statements for wether or not to perform actions based on field values, i.e.: just pass if an AssignmentSubmission record has a "Not Turned In" status.

Check Django signals reference for more information about when they trigger.

Additionally, I would suggest to change the "state" field to boolean type and rename it to something more explicit like "is_turned_in" for ease of use. It will simplify your forms and code. Edit: For more than 2 choices (non-boolean), I prefer using ForeignKey instead. It will let you modify choices over time easily from the admin site.

Edit:

Another approach could be mirroring the original models in your gamification app and call for a mirror record update when a save method is used in the original model.

gamification_app/models.py:

from parent_app.models import OriginalModel # The model you want to track from a parent app.
from django.db.models.signals import post_save # I'm choosing post_save just to illustrate.
from django.dispatch import receiver
from datetime import datetime

def gamification_function(input, parameters):
    output = *your gamification logic*
    return output
    

class MirrorModel(models.Model):
    original_model = (OriginalModel, verbose_name=("Original Model"), on_delete=models.CASCADE)
    field_one = ... #Same type as the original
    field_two = ...
    field_n = ...

    @hook(AFTER_UPDATE, when="field_n", was=OPTION_1, is_now=OPTION_2)
    def on_turn_in(self):
        gamification_function(self.field, self.other_field)

@receiver(post_save, sender=OriginalModel) # Here, listen for the save signal coming from the original app change record.
def mirror_model_update(instance, **kwargs):
    pk = instance.pk
    mirror_model = []
    if MirrorModel.objects.get(original_model.pk=pk).exists():
        mirror_model = MirrorModel.objects.get(original_model.pk=pk)
        mirror_model.field_one = instance.field_one # Map field values
        *other logic ...*
        mirror_model.save() # This should trigger your hook
    else:
        mirror_model = MirrorModel(original_model = instance, field_one = instance.field_one, ...)
        mirror_model.save() #This should trigger your hooks as well, but for a new record

This way you can decouple the gamification app and even choose not to keep a record of all or the same fields as the original model, but fields specific to your functionality needs.