Skip to content

Commit

Permalink
Merge pull request #882 from kobotoolbox/footer_disclaimer
Browse files Browse the repository at this point in the history
Add footer disclaimer to Open Rosa XML
  • Loading branch information
jnm committed Aug 14, 2023
2 parents eaa8ff6 + 77cd975 commit d5bb016
Show file tree
Hide file tree
Showing 9 changed files with 273 additions and 4 deletions.
3 changes: 1 addition & 2 deletions onadata/apps/api/viewsets/xform_list_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ def list(self, request, *args, **kwargs):

def retrieve(self, request, *args, **kwargs):
self.object = self.get_object()

return Response(self.object.xml, headers=self.get_openrosa_headers())
return Response(self.object.xml_with_disclaimer, headers=self.get_openrosa_headers())

@action(detail=True, methods=['GET'])
def manifest(self, request, *args, **kwargs):
Expand Down
6 changes: 6 additions & 0 deletions onadata/apps/form_disclaimer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class FormDisclaimerAppConfig(AppConfig):
name = 'onadata.apps.form_disclaimer'
verbose_name = 'Form disclaimer'
39 changes: 39 additions & 0 deletions onadata/apps/form_disclaimer/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 3.2.15 on 2023-07-03 17:54

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
('logger', '0027_on_delete_cascade_monthlyxformsubmissioncounter'),
]

operations = [
migrations.CreateModel(
name='FormDisclaimer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('language_code', models.CharField(db_index=True, max_length=5, null=True)),
('message', models.TextField(default='')),
('default', models.BooleanField(default=False)),
('hidden', models.BooleanField(default=False)),
('xform', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='disclaimers', to='logger.xform')),
],
),
migrations.AddConstraint(
model_name='formdisclaimer',
constraint=models.UniqueConstraint(fields=('language_code', 'xform'), name='uniq_disclaimer_with_xform'),
),
migrations.AddConstraint(
model_name='formdisclaimer',
constraint=models.UniqueConstraint(condition=models.Q(('xform', None)), fields=('language_code',), name='uniq_disclaimer_without_xform'),
),
migrations.AddConstraint(
model_name='formdisclaimer',
constraint=models.UniqueConstraint(condition=models.Q(('hidden', True)), fields=('xform', 'hidden'), name='uniq_hidden_disclaimer_per_xform'),
),
]
Empty file.
35 changes: 35 additions & 0 deletions onadata/apps/form_disclaimer/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.db import models
from django.db.models import Q
from django.db.models.constraints import UniqueConstraint


class FormDisclaimer(models.Model):

language_code = models.CharField(max_length=5, null=True, db_index=True)
xform = models.ForeignKey(
'logger.xform',
related_name='disclaimers',
null=True,
on_delete=models.CASCADE,
)
message = models.TextField(default='')
default = models.BooleanField(default=False)
hidden = models.BooleanField(default=False)

class Meta:
constraints = [
UniqueConstraint(
fields=['language_code', 'xform'],
name='uniq_disclaimer_with_xform',
),
UniqueConstraint(
fields=['language_code'],
condition=Q(xform=None),
name='uniq_disclaimer_without_xform',
),
UniqueConstraint(
fields=['xform', 'hidden'],
condition=Q(hidden=True),
name='uniq_hidden_disclaimer_per_xform',
),
]
10 changes: 9 additions & 1 deletion onadata/apps/logger/models/xform.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from django.db import models
from django.db.models.signals import post_save, post_delete, pre_delete
from django.utils.encoding import smart_str

from django.utils.translation import gettext_lazy as t
from guardian.shortcuts import (
assign_perm,
Expand All @@ -32,6 +31,7 @@
CAN_DELETE_DATA_XFORM,
CAN_TRANSFER_OWNERSHIP,
)
from onadata.libs.utils.xml import XMLFormWithDisclaimer
from onadata.libs.models.base_model import BaseModel
from onadata.libs.utils.hash import get_hash

Expand Down Expand Up @@ -242,6 +242,10 @@ def time_of_last_submission_update(self):
def md5_hash(self):
return get_hash(self.xml)

@property
def md5_hash_with_disclaimer(self):
return get_hash(self.xml_with_disclaimer)

@property
def can_be_replaced(self):
if hasattr(self.submission_count, '__call__'):
Expand Down Expand Up @@ -289,6 +293,10 @@ def settings(self):
"validation_statuses": default_validation_statuses
}

@property
def xml_with_disclaimer(self):
return XMLFormWithDisclaimer(self).get_object().xml


def update_profile_num_submissions(sender, instance, **kwargs):
profile_qs = User.profile.get_queryset()
Expand Down
2 changes: 1 addition & 1 deletion onadata/libs/serializers/xform_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def get_version(self, obj):

@check_obj
def get_hash(self, obj):
return "md5:%s" % obj.md5_hash
return f'md5:{obj.md5_hash_with_disclaimer}'

@check_obj
def get_url(self, obj):
Expand Down
181 changes: 181 additions & 0 deletions onadata/libs/utils/xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from __future__ import annotations

import re
from typing import Optional, Union
from xml.dom import Node

from defusedxml import minidom
from django.db.models import F, Q
from django.db.models.query import QuerySet

from onadata.apps.form_disclaimer.models import FormDisclaimer


class XMLFormWithDisclaimer:

# TODO merge this with KPI when Kobocat becomes a Django-app
def __init__(self, obj: Union['logger.XForm']):
self._object = obj
self._unique_id = obj.id_string
self._add_disclaimer()

def get_object(self):
return self._object

def _add_disclaimer(self):

if not (disclaimers := self._get_disclaimers(self._object)):
return

if not (value := self._get_translations(disclaimers)):
return

translated, disclaimers_dict, default_language_code = value

self._root_node = minidom.parseString(self._object.xml)

if translated:
self._add_translation_nodes(disclaimers_dict, default_language_code)

self._add_instance_and_bind_nodes()
self._add_disclaimer_input(
translated, disclaimers_dict, default_language_code
)

self._object.xml = self._root_node.toxml(encoding='utf-8').decode()

def _add_instance_and_bind_nodes(self):
# Search for main <model> node
model_node = [
n
for n in self._root_node.getElementsByTagName('h:head')[0].childNodes
if n.nodeType == Node.ELEMENT_NODE and n.tagName == 'model'
][0]

# Inject <bind nodeset /> inside <model odk:xforms-version="1.0.0">
bind_node = self._root_node.createElement('bind')
bind_node.setAttribute(
'nodeset', f'/{self._unique_id}/_{self._unique_id}__disclaimer'
)
bind_node.setAttribute('readonly', 'true()')
bind_node.setAttribute('required', 'false()')
bind_node.setAttribute('type', 'string')
bind_node.setAttribute('relevant', 'false()')
model_node.appendChild(bind_node)

# Inject note node inside <{self._unique_id}>
instance_node = model_node.getElementsByTagName('instance')[0]
instance_node = instance_node.getElementsByTagName(self._unique_id)[0]
instance_node.appendChild(
self._root_node.createElement(f'_{self._unique_id}__disclaimer')
)

def _add_disclaimer_input(
self,
translated: bool,
disclaimers_dict: dict,
default_language_code: str,
):
"""
"""
body_node = self._root_node.getElementsByTagName('h:body')[0]
disclaimer_input = self._root_node.createElement('input')
disclaimer_input_label = self._root_node.createElement('label')
disclaimer_input.setAttribute('appearance', 'kobo-disclaimer')
disclaimer_input.setAttribute(
'ref', f'/{self._unique_id}/_{self._unique_id}__disclaimer'
)

if translated:
itext = f'/{self._unique_id}/_{self._unique_id}__disclaimer:label'
disclaimer_input_label.setAttribute(
'ref',
f"jr:itext('{itext}')",
)
else:
disclaimer_input_label.appendChild(
self._root_node.createTextNode(
disclaimers_dict[default_language_code]
)
)

disclaimer_input.appendChild(disclaimer_input_label)
body_node.appendChild(disclaimer_input)

def _add_translation_nodes(
self, disclaimers_dict: dict, default_language_code: str
):
"""
Add <itext> nodes to <instance> if translations are detected.
Will add only translations that match form translations.
"""

for n in self._root_node.getElementsByTagName('itext')[0].childNodes:
if n.nodeType == Node.ELEMENT_NODE and n.tagName == 'translation':
disclaimer_translation = self._root_node.createElement('text')
disclaimer_translation.setAttribute(
'id',
f'/{self._unique_id}/_{self._unique_id}__disclaimer:label',
)
value = self._root_node.createElement('value')
language = n.getAttribute('lang').lower().strip()
if m := re.match(r'[^\(]*\(([a-z]{2,})\)', language):
language_code = m.groups()[0]
else:
language_code = default_language_code

value.appendChild(
self._root_node.createTextNode(
disclaimers_dict.get(
language_code,
disclaimers_dict.get(default_language_code)
)
)
)
disclaimer_translation.appendChild(value)
n.appendChild(disclaimer_translation)

def _get_disclaimers(self, xform: 'logger.XForm') -> Optional[QuerySet]:

# Order by '-message' to ensure that default is overridden later if
# an override exists for the same language. See `_get_translations()`
disclaimers = (
FormDisclaimer.objects.values(
'language_code', 'message', 'default', 'hidden'
)
.filter(Q(xform__isnull=True) | Q(xform=xform))
# Hidden first, per-asset (non-null xform) first, then alphabetical
# by language code
.order_by('-hidden', '-xform_id', 'language_code')
)

if not disclaimers:
return

return disclaimers

def _get_translations(
self, disclaimers: QuerySet
) -> Optional[tuple[bool, dict, str]]:
"""
Detect whether the form is translated and return its value plus a dictionary
of all available messages and the default language code.
"""

# Do not go further if disclaimer must be hidden
if disclaimers[0]['hidden']:
return

translated = '<itext>' in self._object.xml
disclaimers_dict = {}
default_language_code = None
for d in disclaimers:
disclaimers_dict[d['language_code']] = d['message']
if d['default']:
default_language_code = d['language_code']

if not translated and not disclaimers_dict[default_language_code]:
return

return translated, disclaimers_dict, default_language_code
1 change: 1 addition & 0 deletions onadata/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ def skip_suspicious_operations(record):
'pure_pagination',
'django_celery_beat',
'django_extensions',
'onadata.apps.form_disclaimer.FormDisclaimerAppConfig',
]

USE_THOUSAND_SEPARATOR = True
Expand Down

0 comments on commit d5bb016

Please sign in to comment.