This post is kind of continuation of my last post on Inventory app with DRF in Django. As an enhancement, an approval workflow to moderate any updation/deletion/addition of a new model entry was to be put in place. Now, I did look up for ready made solutions and could find a few open source ones – Django Moderation was one of them. However, it had its limitation and was not compatible with the latest Django 3.
So, I decided to come-up with a custom solution utilizing the DRF framework as well. This post basically outlines the approach I took, the challenges I faced and how they were resolved.
To start with, below is the flow diagram of the approval workflow that has been implemented:

The approach followed to implement the above:
- Create an abstract model which needs moderation
- Create 2 models inheriting the abstract model – One acts as the primary model, and the other an approval model, where changes can be moderated and then saved to the primary model based on actions taken by moderator
- Instead of using Django admin site for data entry, create a form (template) to Add/Update data – Used DRF Forms for this as all permission and field serialization is handled by DRF – so effective DRY principal!
- Create a separate template for the Approval model, from where entries can be moderated.
Whenever an entry is added/updated, it is first saved in the approval model. On successful moderation, the entry is saved in the primary model. Sounds simple right? 😉 Well, let’s find that out.
Now, a lot of templating work can be prevented in-case you go with the out of the box Django admin site, however I would suggest to spend some time to have templates designed for these actions which would give a more professional look to the site and also allow granular customizations. (especially in the UI)
Let’s have a look at the model:
# Abstract Model
class AbstractServer(models.Model):
component = models.ForeignKey(Component, blank=False, null=False, on_delete=models.CASCADE)
name = models.CharField(max_length=50, unique=True, blank=False, null=False)
ip = models.GenericIPAddressField(unique=True, blank=False, null=False)
dc = models.ForeignKey(Dc, blank=False, null=False, on_delete=models.CASCADE)
environment = models.ForeignKey(Environment, blank=False, null=False, on_delete=models.CASCADE)
type = models.ForeignKey(Type, blank=True, null=True, on_delete=models.CASCADE)
state = models.CharField(
max_length=10,
choices=STATES,
blank=False
)
tags = models.ManyToManyField(Tag, blank=True)
group = models.ForeignKey(Group, blank=False, null=False, on_delete=models.CASCADE)
description = models.TextField(blank=True, null=True)
requestor = models.ForeignKey(User, blank=False, null=False, default=1, on_delete=models.CASCADE)
class Meta:
abstract = True
# Primary Model (inherited from abstract model)
class Server(AbstractServer):
changed_by = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE, related_name='server_changed_by')
history = HistoricalRecords()
@property
def _history_user(self):
return self.changed_by
@_history_user.setter
def _history_user(self, value):
self.changed_by = value
def __str__(self):
return self.name
# Approval Model (inherited from abstract model)
class ServerApproval(AbstractServer):
action = models.CharField(
max_length=10,
choices=APPROVAL_ACTIONS,
blank=False
)
approver = models.ForeignKey(User, blank=False, null=False, on_delete=models.CASCADE, related_name='server_approver')
status = models.CharField(
max_length=10,
choices=APPROVAL_STATES,
blank=False
)
datetime = models.DateTimeField(blank=False)
comments = models.TextField(blank=True, null=True)
def __str__(self):
return self.name
Note the ‘abstract=True‘ flag in the abstract model’s meta class. This is to ensure that the abstract model is not created in DB and only the inherited models are. Perfect use-case for our requirement. You can read more about model inheritance from the Django official documentation. There are some fields specific to the primary model as it has django-simple-histiory enabled, while the approval model has fields such as action, status, approver very specific to the model to capture moderation details as the intention is to save these information as well.
Now that our model is ready, let’s see how we can use DRF to provide a form to the user to add/update changes. And these changes should be read from the primary model however saved in the approval model first. Now the same thing can be done using Django formset totally leaving out DRF. However as we already have DRF configured and things as such as permissions, field serialization already done, I found using DRF’s TemplateHTMLRenderer a much elegant solution.
Once DRF is setup, meaning you have necessary serializers, url routers in place, we would need an additional approval model view to render the form and also save the details in the approval model.
# Server edit form backend
class ServerEditForm(APIView):
permission_classes = [IsAuthenticatedOrStaffUser,]
renderer_classes = [TemplateHTMLRenderer]
template_name = 'server_edit_form.html'
style = {'vertical_style': {'template_pack': 'rest_framework/vertical'},
'horizontal_style': {'template_pack': 'rest_framework/horizontal'}}
def get(self, request, **kwargs):
param = kwargs.get('param')
# Pass users
users = User.objects.exclude(id=request.user.id)
# Render add server form
if param == 'add':
serializer = ServerApprovalWriteSerializer()
return Response({'serializer': serializer, 'action': 'add', 'users': users, 'style': self.style})
# Render update server form
elif 'update' in param:
server = get_object_or_404(Server, pk=kwargs.get('param').replace('update',''))
serializer = ServerWriteSerializer(server)
return Response({'serializer': serializer, 'server': server, 'users': users, 'style': self.style})
def post(self, request, **kwargs):
serializer = None
param = kwargs.get('param')
# Update status and requestor fields
request.data._mutable = True
request.data['status'] = 'Pending'
request.data['approver'] = request.POST.get('approver')
request.data['requestor'] = request.user.pk
request.data['datetime'] = datetime.datetime.now()
# Submit add server request
if param == 'add':
request.data['action'] = 'Add'
name = request.data['name']
ip = request.data['ip']
# Validate if server exists in Server model
if Server.objects.filter(name=name).exists() or Server.objects.filter(ip=ip).exists():
return JsonResponse({'response': 'ERROR adding server
Server/IP already exists!'})
# Validate if server exists in ServerApproval model
if ServerApproval.objects.filter(name=name).exists() or ServerApproval.objects.filter(ip=ip).exists():
server = ServerApproval.objects.get(Q(name=name) | Q(ip=ip))
if (server.status == 'Pending' or server.status == 'On Hold'):
return JsonResponse({'response': 'ERROR adding server
Existing request found for server({0})/IP({1})! You may raise a new request once the existing request is Approved or Rejected'.format(name, ip)})
else:
# Updating server approval entry
serializer = ServerApprovalWriteSerializer(server, data=request.data)
else:
# Add new entry
serializer = ServerApprovalWriteSerializer(context={'request': request}, data=request.data)
# Submit update server request
else:
try:
request.data['action'] = request.POST.get('action').capitalize()
request.data['comments'] = request.POST.get('comments')
existing_server = get_object_or_404(Server, pk=kwargs.get('param'))
server = ServerApproval.objects.get(name=existing_server.name)
serializer = ServerApprovalWriteSerializer(server, data=request.data)
except ServerApproval.DoesNotExist:
# Server does not exists in approval queue! Create new entry in approval queue
serializer = ServerApprovalWriteSerializer(context={'request': request}, data=request.data)
# Handle serializer error
if not serializer.is_valid():
return JsonResponse({'response': 'ERROR submitting server details
{0}'.format(str(serializer.errors))})
# Save changes
serializer.save()
# Redirect to response page
return JsonResponse({'response': 'Change successfully submitted to approval queue!'})
Now, the approval model APIView class as shown above, might look a little complicated however, much of it is because of the extra fields and requirement specific customizations I had. In simpler terms. what it does is, override the GET and POST methods of the APIView. The GET method returns the form fields which is rendered in template and shown to user to request for entry addition/updates. If you notice the serializers used in GET, for additions it uses ServerApproval serializer and for updations it uses Server serializer. This is done to get primary model fields for updations and approval model fields for new entries. For POST which actually performs the save(), only ServerApproval serializer is used, meaning changes will be read from primary model but will be saved into the new approval model only, which is what we want. Apart from this, there are some checks/validations to handle edge cases. (for e.g. User tries to update entry already requested and so on). There are some fields which are set explicitly, like requestor/datetime/status which basically is not rendered in the form as we do-not want the user to specify these.
Now, lets have a look at the form template and url patterns.
# urls.py
url('^server_approval/?$', views.server_approval, name='server_approval'),
url('^review_data/?$', manager.review_data, name='review_data'),
url('^submit_review/?$', manager.submit_review, name='submit_review'),
url('^server_edit_form/(?P[^/]+)', views.ServerEditForm.as_view(), name='server_edit_form'),
# server_edit_form.html
{% load static %}
{% load rest_framework %}
<html>
<body>
{% url 'server_edit_form' param='add' as add_url %}
{% url 'server_edit_form' param='update' as update_url %}
{% if request.get_full_path == add_url %}
<br>
<form name="addForm" action="proxy.php?url=https%3A%2F%2Fb2techblog.wordpress.com%2F%7B%25+url+"server_edit_form' param='add' %}" method="POST">
{% csrf_token %}
<table>
<tr><td class="required">{% render_field serializer.component style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.name style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.ip style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.dc style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.environment style=style.vertical_style %}</td></tr>
<tr><td>{% render_field serializer.type style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.state style=style.vertical_style %}</td></tr>
<tr><td>{% render_field serializer.tags style=style.horizontal_style %}</td></tr>
<tr><td class="required">{% render_field serializer.group style=style.vertical_style %}</td></tr>
<tr><td class="required">
<label>Approver</label>
<select name="approver" class="form-control">
{% for user in users %}
<option value="{{ user.pk }}" selected>{{ user }}</option>
{% endfor %}
</select>
</td></tr>
<tr><td>{% render_field serializer.description style=style.horizontal_style %}</td></tr>
<tr><td>{% render_field serializer.comments style=style.horizontal_style %}</td></tr>
</table>
</form><br>
<center><font color=red><b>*</b></font><i> indicates <b>mandatory</b> fields</i></center>
{% elif update_url in request.get_full_path %}
<br>
<form name="editForm" action="proxy.php?url=https%3A%2F%2Fb2techblog.wordpress.com%2F%7B%25+url+"server_edit_form' param=server.pk %}" method="POST">
{% csrf_token %}
<table>
<tr><td class="required">
<label>Action</label>
<select name="action" class="form-control">
<option value="Update" selected>Update</option>
<option value="Delete">Delete</option>
</select>
</td>
<tr><td class="required">{% render_field serializer.component style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.name style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.ip style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.dc style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.environment style=style.vertical_style %}</td></tr>
<tr><td>{% render_field serializer.type style=style.vertical_style %}</td></tr>
<tr><td class="required">{% render_field serializer.state style=style.vertical_style %}</td></tr>
<tr><td>{% render_field serializer.tags style=style.horizontal_style %}</td></tr>
<tr><td class="required">{% render_field serializer.group style=style.vertical_style %}</td></tr>
<tr><td class="required">
<label>Approver</label>
<select name="approver" class="form-control">
{% for user in users %}
<option value="{{ user.pk }}" selected>{{ user }}</option>
{% endfor %}
</select>
</td></tr>
<tr><td>{% render_field serializer.description style=style.horizontal_style %}</td></tr>
<tr><td>
<label>Comments</label><br>
<textarea name="comments" class="form-control"></textarea>
</td></tr>
</table>
</form><br>
<center><font color=red><b>*</b></font><i> indicates <b>mandatory</b> fields</i></center>
{% endif %}
</body>
</html>
I have removed css/js from the template to reduce the LOC, however you can see how the DRF form is rendered. Some fields for which I want custom data (e.g. User fields with only current user), I do-not use the form-renderer, however simple HTML inputs. If you go back to the APIView class, you will find, data for these custom fields are fed separately to the serializer. You can find more information about DRF form rendering in the DRF official documentation. One thing I would like to highlight is, to customize the UI (e.g. add validations/style etc) a lot of element overrides needs to be done as the out of the box styling for DRF forms is pretty basic.
So majority of the work is completed and the only thing pending is handling the approval model template/actions. I additionally enhanced the approval model template to display a comparison table for update requests which show original data vs changes done for the entry. Below in the views.py you may refer the function to achieve this.
# views.py
# Retrieve review details
@csrf_exempt
def review_data(request):
review_data = []
review_error = ""
for item in request.POST.dict():
entry = json.loads(item)
existing_server = Server.objects.get(name=entry['host'])
existing_tags = []
for tag in existing_server.tags.all():
existing_tags.append(tag.name)
# Fetch existing server entry
try:
review_data.append({'component': {'existing_component': existing_server.component.name,
'new_component': entry['component'],
'class': get_diff(existing_server.component.name, entry['component'])},
'host': {'existing_host': existing_server.name,
'new_host': entry['host'],
'class': get_diff(existing_server.name, entry['host'])},
'ip': {'existing_ip': existing_server.ip,
'new_ip': entry['ip'],
'class': get_diff(existing_server.ip, entry['ip'])},
'dc': {'existing_dc': existing_server.dc.name,
'new_dc': entry['dc'],
'class': get_diff(existing_server.dc.name, entry['dc'])},
'env': {'existing_env': existing_server.environment.name,
'new_env': entry['env'],
'class': get_diff(existing_server.environment.name, entry['env'])},
'type': {'existing_type': existing_server.type.name,
'new_type': entry['type'],
'class': get_diff(existing_server.type.name, entry['type'])},
'state': {'existing_state': existing_server.state,
'new_state': entry['state'],
'class': get_diff(existing_server.state, entry['state'])},
'tags': {'existing_tags': existing_tags,
'new_tags': entry['tags'],
'class': get_diff(existing_tags, entry['tags'])},
'group': {'existing_group': existing_server.group.name,
'new_group': entry['group'],
'class': get_diff(existing_server.group.name, entry['group'])},
'desc': {'existing_desc': existing_server.description,
'new_desc': entry['desc'],
'class': get_diff(existing_server.description, entry['desc'])}
})
except Exception as err:
review_error = str(err)
return HttpResponse(json.dumps({"review_data": review_data, "review_error": review_error}, cls = DjangoJSONEncoder, indent = None, separators = (',', ':')),
content_type='application/json;charset=utf-8')
Basically, the approval model entry id is returned from the approval template and its corresponding primary model data is matched and returned back to the template to show the comparison.
For the final save, a simple model save action is triggered when the approval status is updated from the UI template.
# views.py
# Submit review
@csrf_exempt
def submit_review(request):
review_data = []
review_error = ""
# Save entry based on action
try:
action = request.POST.get('action')
server_approval = ServerApproval.objects.get(pk=request.POST.get('id'))
if action == 'approved':
server_approval.status = 'Approved'
review_data.append({'response': 'Change APPROVED successfully!'})
elif action == 'onhold':
server_approval.status = 'On Hold'
review_data.append({'response': 'Change status set to ON HOLD'})
elif action == 'rejected':
server_approval.status = 'Rejected'
review_data.append({'response': 'Change REJECTED successfully!'})
server_approval.datetime = datetime.datetime.now()
server_approval.save()
except Exception as err:
review_error = str(err)
return HttpResponse(json.dumps({"review_data": review_data, "review_error": review_error}, cls = DjangoJSONEncoder, indent = None, separators = (',', ':')),
content_type='application/json;charset=utf-8')
All invocations is done using AJAX and the response is returned as in the functions above to display to the user.
When a model approval change is saved with status approved, a post-save signal is used to save the entry to the primary model as below:
# Signal to handle server approval
@receiver(post_save, sender=ServerApproval)
def create_update_approved_server(sender, instance, **kwargs):
# Add server entry on approval
if instance.status == 'Approved' and instance.action == 'Add':
server_obj = Server.objects.create(name = instance.name,
component = instance.component,
ip = instance.ip,
dc = instance.dc,
environment = instance.environment,
type = instance.type,
state = instance.state,
group = instance.group,
description = instance.description,
requestor = instance.requestor,
changed_by = instance.approver
)
server_obj.tags.add(*instance.tags.all())
# Update server entry on approval
if instance.status == 'Approved' and instance.action == 'Update':
server_obj, created = Server.objects.update_or_create(name = instance.name,
defaults = {'component': instance.component,
'ip': instance.ip,
'dc': instance.dc,
'environment': instance.environment,
'type': instance.type,
'state': instance.state,
'group': instance.group,
'description': instance.description,
'requestor': instance.requestor,
'changed_by': instance.approver
}
)
server_obj.tags.add(*instance.tags.all())
# Delete server entry on approval
if instance.status == 'Approved' and instance.action == 'Delete':
server_obj = Server.objects.get(name = instance.name)
server_obj.delete()
As I had a many-to-many field, had to add those after the model was saved using the .add () method as shown above. The signal ensures to add an entry to the primary table if not present (for add requests) and update an existing one if already present (update requests).
I am using datatables with jquery for the template. Snippet to display the approval form (in jquery dialog)
$("#actionDialog").dialog({
title: dialogTitle,
position : [ 'center' ],
width: '500px',
height: 'auto',
autoOpen: false,
closeOnEscape: false,
modal: true,
open: function () {
$("#actionStatus").load(url, function(data, status, jqXGR) {
if(status == 'error') {
$("#actionWait").html('<br><font color=red><b>Error retrieving server details!</b></font>');
} else {
$("#actionWait").remove();
}
});
},
buttons:[
{
text: 'Save',
id: 'actionBtn',
click: function () {
if (validateForm()) {
$("#actionDialog").scrollTop("0");
$("#actionDialog").prepend("<center id='actionWait'><br><div class='spinner'><br><br></div></center>");
$("#actionBtn").addClass("ui-state-disabled").attr("disabled", true);
// AJAX call to submit changes
$.ajax({
type:'POST',
url: $("form[name='" + formName + "']").attr('action'),
data: $("form[name='" + formName + "']").serialize(),
success: function(message) {
$("#actionStatus").html('<center><br><br>' + message.response + '</center>');
},
error: function(error) {
$("#actionStatus").html('<center><font color=red><br><b>ERROR submitting changes!</b><br>' + error + '</font></center>');
},
complete: function(jqXHR, textStatus) {
$("#actionWait").remove();
}
});
}
}
},
{
text: 'Close',
click: function () {
resetActionDialog();
$(this).dialog('close');
}
}
]
}).dialog('open');
I hope you have got a basic understanding of how we can achieve moderation in Django models. The solution works seamlessly and also gives us control to customize stuff based on requirements.
I think thats a pretty long post. Hope you don’t get bored reading it 😉