Skip to content

Commit

Permalink
Merge pull request #2450 from onaio/multiple-domains-update
Browse files Browse the repository at this point in the history
Ensure onadata can work in a multi-domain setup
  • Loading branch information
FrankApiyo authored Jul 25, 2023
2 parents 28d2990 + 9e4b0ac commit dcfe4dd
Show file tree
Hide file tree
Showing 23 changed files with 309 additions and 101 deletions.
2 changes: 1 addition & 1 deletion docs/projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ adding the setting ``PROJECT_INVITATION_URL``

::

PROJECT_INVITATION_URL = 'https://example.com/register'
PROJECT_INVITATION_URL = {'*': 'https://example.com/register'}


Update a project invitation
Expand Down
43 changes: 39 additions & 4 deletions onadata/apps/api/tests/viewsets/test_project_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2841,7 +2841,13 @@ def test_only_admins_allowed(self, mock_send_mail):
else:
self.assertEqual(response.status_code, 403)

@override_settings(PROJECT_INVITATION_URL="https://example.com/register")
@override_settings(
PROJECT_INVITATION_URL={
"*": "https://example.com/register",
"onadata.com": "https://onadata.com/register",
}
)
@override_settings(ALLOWED_HOSTS=["*"])
def test_create_invitation(self, mock_send_mail):
"""Project invitation can be created"""
post_data = {
Expand Down Expand Up @@ -2882,6 +2888,35 @@ def test_create_invitation(self, mock_send_mail):
response = self.view(request, pk=self.project.pk)
self.assertEqual(response.status_code, 400)

# Project invitations are created for non-default host
post_data = {
"email": "[email protected]",
"role": "editor",
}
request = self.factory.post(
"/",
data=json.dumps(post_data),
content_type="application/json",
**self.extra,
)
request.META["HTTP_HOST"] = "onadata.com"
response = self.view(request, pk=self.project.pk)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.project.invitations.count(), 2)
invitation = self.project.invitations.last()
self.assertEqual(
response.data,
{
"id": invitation.pk,
"email": "[email protected]",
"role": "editor",
"status": 1,
},
)
mock_send_mail.assert_called_with(
invitation.pk, "https://onadata.com/register"
)

def test_email_required(self, mock_send_mail):
"""email is required"""
# blank string
Expand Down Expand Up @@ -3073,7 +3108,7 @@ def test_only_admins_allowed(self, mock_send_mail):
else:
self.assertEqual(response.status_code, 403)

@override_settings(PROJECT_INVITATION_URL="https://example.com/register")
@override_settings(PROJECT_INVITATION_URL={"*": "https://example.com/register"})
def test_update(self, mock_send_mail):
"""We can update an invitation"""
payload = {
Expand Down Expand Up @@ -3133,7 +3168,7 @@ def test_update_role_only(self, mock_send_mail):
)
mock_send_mail.assert_not_called()

@override_settings(PROJECT_INVITATION_URL="https://example.com/register")
@override_settings(PROJECT_INVITATION_URL={"*": "https://example.com/register"})
def test_update_email_only(self, mock_send_mail):
"""We can update email only"""
payload = {
Expand Down Expand Up @@ -3351,7 +3386,7 @@ def test_only_admins_allowed(self, mock_send_mail):

mock_send_mail.assert_not_called()

@override_settings(PROJECT_INVITATION_URL="https://example.com/register")
@override_settings(PROJECT_INVITATION_URL={"*": "https://example.com/register"})
def test_resend_invite(self, mock_send_mail):
"""Invitation is revoked"""
invitation = self.project.invitations.create(
Expand Down
3 changes: 3 additions & 0 deletions onadata/apps/api/tests/viewsets/test_tableau_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.test import RequestFactory
from tempfile import NamedTemporaryFile
from django.utils.dateparse import parse_datetime
from django.test.utils import override_settings
from onadata.apps.main.tests.test_base import TestBase
from onadata.apps.logger.models.open_data import get_or_create_opendata
from onadata.apps.api.viewsets.v2.tableau_viewset import (
Expand Down Expand Up @@ -277,6 +278,7 @@ def test_clean_xform_headers(self):
cleaned_data = clean_xform_headers(group_columns)
self.assertEqual(cleaned_data, ["childs_name", "childs_age"])

@override_settings(ALLOWED_HOSTS=["*"])
def test_replace_media_links(self):
"""
Test that attachment details exported to Tableau contains
Expand Down Expand Up @@ -313,6 +315,7 @@ def test_replace_media_links(self):
_open_data = get_or_create_opendata(xform_w_attachments)
uuid = _open_data[0].uuid
request = self.factory.get("/", **self.extra)
request.META["HTTP_HOST"] = "example.com"
response = self.view(request, uuid=uuid)
self.assertEqual(response.status_code, 200)
# cast generator response to list for easy manipulation
Expand Down
24 changes: 20 additions & 4 deletions onadata/apps/api/tests/viewsets/test_xform_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ def test_existing_form_format(self):
formid = self.xform.pk
request = self.factory.get("/", **self.extra)
# get existing form format
exsting_format = get_existing_file_format(self.xform.xls, 'xls')
exsting_format = get_existing_file_format(self.xform.xls, "xls")

# XLSX format
response = view(request, pk=formid, format="xlsx")
Expand Down Expand Up @@ -1226,7 +1226,13 @@ def test_login_enketo_no_redirect(self):
"Authentication failure, cannot redirect",
)

@override_settings(ENKETO_CLIENT_LOGIN_URL="http://test.ona.io/login")
@override_settings(
ENKETO_CLIENT_LOGIN_URL={
"*": "http://test.ona.io/login",
"stage-testserver": "http://gh.ij.kl/login",
}
)
@override_settings(ALLOWED_HOSTS=["*"])
def test_login_enketo_no_jwt_but_with_return_url(self):
with HTTMock(enketo_urls_mock):
self._publish_xls_form_to_project()
Expand All @@ -1237,9 +1243,16 @@ def test_login_enketo_no_jwt_but_with_return_url(self):
url = "https://enketo.ona.io/::YY8M"
query_data = {"return": url}
request = self.factory.get("/", data=query_data)

# user is redirected to default login page "*"
response = view(request, pk=formid)
self.assertTrue(response.url.startswith("http://test.ona.io/login"))
self.assertEqual(response.status_code, 302)

# user is redirected to the set login page in settings file
# user is redirected to login page for "stage-testserver"
request.META["HTTP_HOST"] = "stage-testserver"
response = view(request, pk=formid)
self.assertTrue(response.url.startswith("http://gh.ij.kl/login"))
self.assertEqual(response.status_code, 302)

@override_settings(JWT_SECRET_KEY=JWT_SECRET_KEY, JWT_ALGORITHM=JWT_ALGORITHM)
Expand Down Expand Up @@ -4307,6 +4320,7 @@ def test_csv_export__with_and_without_do_not_split_select_multiples(self):
self.assertNotEqual(multiples_select_split, no_multiples_select_split)
self.assertGreater(multiples_select_split, no_multiples_select_split)

@override_settings(ALLOWED_HOSTS=["*"])
def test_csv_export_with_and_without_removed_group_name(self):
with HTTMock(enketo_mock):
self._publish_xls_form_to_project()
Expand All @@ -4331,6 +4345,7 @@ def test_csv_export_with_and_without_removed_group_name(self):

data = {"remove_group_name": True}
request = self.factory.get("/", data=data, **self.extra)
request.META["HTTP_HOST"] = "example.com"
response = view(request, pk=self.xform.pk, format="csv")
self.assertEqual(response.status_code, 200)

Expand Down Expand Up @@ -4960,6 +4975,7 @@ def test_export_form_data_async_include_labels_only(self, async_result):
headers = next(csv_reader)
self.assertIn("Is ambulance available daily or weekly?", headers)

@override_settings(ALLOWED_HOSTS=["*"])
def test_csv_exports_w_images_link(self):
with HTTMock(enketo_mock):
xlsform_path = os.path.join(
Expand Down Expand Up @@ -5009,6 +5025,7 @@ def test_csv_exports_w_images_link(self):
data = {"include_images": True}
# request for export again
request = self.factory.get("/", data=data, **self.extra)
request.META["HTTP_HOST"] = "example.com"
response = view(request, pk=self.xform.pk, format="csv")
self.assertEqual(response.status_code, 200)

Expand Down Expand Up @@ -5239,7 +5256,6 @@ def test_share_auto_xform_meta_perms(self):
MetaData.xform_meta_permission(self.xform, data_value=data_value)

for role_class in ROLES_ORDERED:

data = {"username": "alice", "role": role_class.name}
request = self.factory.post("/", data=data, **self.extra)
response = view(request, pk=formid)
Expand Down
49 changes: 45 additions & 4 deletions onadata/apps/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ def create_organization_object(org_name, creator, attrs=None):
except IntegrityError as e:
raise ValidationError(_(f"{org_name} already exists")) from e
if email:
site = Site.objects.get(pk=settings.SITE_ID)
site = (
attrs["host"]
if "host" in attrs
else Site.objects.get(pk=settings.SITE_ID).domain
)
registration_profile.send_activation_email(site)
profile = OrganizationProfile(
user=new_user,
Expand Down Expand Up @@ -739,7 +743,6 @@ def update_role_by_meta_xform_perms(xform):
users = get_xform_users(xform)

for user in users:

role = users.get(user).get("role")
if role in editor_role:
role = ROLES.get(meta_perms[0])
Expand All @@ -750,9 +753,15 @@ def update_role_by_meta_xform_perms(xform):
role.add(user, xform)


def replace_attachment_name_with_url(data):
def get_host_domain(request):
"""Get host from reques or check the Site model"""
request_host = request and request.get_host()
return request_host or Site.objects.get_current().domain


def replace_attachment_name_with_url(data, request):
"""Replaces the attachment filename with a URL in ``data`` object."""
site_url = Site.objects.get_current().domain
site_url = get_host_domain(request)

for record in data:
attachments: dict = record.json.get("_attachments")
Expand All @@ -773,3 +782,35 @@ def replace_attachment_name_with_url(data):
except ValueError:
pass
return data


ENKETO_AUTH_COOKIE = getattr(settings, "ENKETO_AUTH_COOKIE", "__enketo")
ENKETO_META_UID_COOKIE = getattr(
settings, "ENKETO_META_UID_COOKIE", "__enketo_meta_uid"
)
ENKETO_META_USERNAME_COOKIE = getattr(
settings, "ENKETO_META_USERNAME_COOKIE", "__enketo_meta_username"
)


def set_enketo_signed_cookies(resp, username=None, json_web_token=None):
"""Set signed cookies for JWT token in the HTTPResponse resp object."""
if not username and not json_web_token:
return None

max_age = 30 * 24 * 60 * 60 * 1000
enketo_meta_uid = {"max_age": max_age, "salt": settings.ENKETO_API_SALT}
enketo = {"secure": False, "salt": settings.ENKETO_API_SALT}

# add domain attribute if ENKETO_AUTH_COOKIE_DOMAIN is set in settings
# i.e. don't add in development environment because cookie automatically
# assigns 'localhost' as domain
if getattr(settings, "ENKETO_AUTH_COOKIE_DOMAIN", None):
enketo_meta_uid["domain"] = settings.ENKETO_AUTH_COOKIE_DOMAIN
enketo["domain"] = settings.ENKETO_AUTH_COOKIE_DOMAIN

resp.set_signed_cookie(ENKETO_META_UID_COOKIE, username, **enketo_meta_uid)
resp.set_signed_cookie(ENKETO_META_USERNAME_COOKIE, username, **enketo_meta_uid)
resp.set_signed_cookie(ENKETO_AUTH_COOKIE, json_web_token, **enketo)

return resp
1 change: 1 addition & 0 deletions onadata/apps/api/viewsets/dataview_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ def export_async(self, request, *args, **kwargs):
dataview = self.get_object()
xform = dataview.xform
options = parse_request_export_options(params)
options["host"] = request.get_host()

options.update(
{
Expand Down
2 changes: 1 addition & 1 deletion onadata/apps/api/viewsets/v2/tableau_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def data(self, request, **kwargs):
instances = self.paginate_queryset(instances)

# Switch out media file names for url links in queryset
data = replace_attachment_name_with_url(instances)
data = replace_attachment_name_with_url(instances, request)
data = process_tableau_data(
TableauDataSerializer(data, many=True).data, xform
)
Expand Down
46 changes: 13 additions & 33 deletions onadata/apps/api/viewsets/xform_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,6 @@
)
from onadata.settings.common import CSV_EXTENSION, XLS_EXTENSIONS

ENKETO_AUTH_COOKIE = getattr(settings, "ENKETO_AUTH_COOKIE", "__enketo")
ENKETO_META_UID_COOKIE = getattr(
settings, "ENKETO_META_UID_COOKIE", "__enketo_meta_uid"
)
ENKETO_META_USERNAME_COOKIE = getattr(
settings, "ENKETO_META_USERNAME_COOKIE", "__enketo_meta_username"
)

# pylint: disable=invalid-name
BaseViewset = get_baseviewset_class()
User = get_user_model()
Expand Down Expand Up @@ -186,29 +178,6 @@ def get_survey_xml(csv_name):
return survey.to_xml()


def set_enketo_signed_cookies(resp, username=None, json_web_token=None):
"""Set signed cookies for JWT token in the HTTPResponse resp object."""
if not username and not json_web_token:
return None

max_age = 30 * 24 * 60 * 60 * 1000
enketo_meta_uid = {"max_age": max_age, "salt": settings.ENKETO_API_SALT}
enketo = {"secure": False, "salt": settings.ENKETO_API_SALT}

# add domain attribute if ENKETO_AUTH_COOKIE_DOMAIN is set in settings
# i.e. don't add in development environment because cookie automatically
# assigns 'localhost' as domain
if getattr(settings, "ENKETO_AUTH_COOKIE_DOMAIN", None):
enketo_meta_uid["domain"] = settings.ENKETO_AUTH_COOKIE_DOMAIN
enketo["domain"] = settings.ENKETO_AUTH_COOKIE_DOMAIN

resp.set_signed_cookie(ENKETO_META_UID_COOKIE, username, **enketo_meta_uid)
resp.set_signed_cookie(ENKETO_META_USERNAME_COOKIE, username, **enketo_meta_uid)
resp.set_signed_cookie(ENKETO_AUTH_COOKIE, json_web_token, **enketo)

return resp


def parse_webform_return_url(return_url, request):
"""
Given a webform url and request containing authentication information
Expand Down Expand Up @@ -251,7 +220,7 @@ def parse_webform_return_url(return_url, request):
else:
username = request.user.username

response_redirect = set_enketo_signed_cookies(
response_redirect = utils.set_enketo_signed_cookies(
response_redirect, username=username, json_web_token=jwt_param
)

Expand Down Expand Up @@ -467,8 +436,18 @@ def login(self, request, **kwargs):
if redirect:
return redirect

# get value of login URL based on host
host = request.get_host()
enketo_client_login_url_setting = settings.ENKETO_CLIENT_LOGIN_URL or {}
enketo_client_login_url = (
host in enketo_client_login_url_setting
and enketo_client_login_url_setting[host]
) or (
"*" in enketo_client_login_url_setting
and enketo_client_login_url_setting["*"]
)
login_vars = {
"login_url": settings.ENKETO_CLIENT_LOGIN_URL,
"login_url": enketo_client_login_url,
"return_url": urlencode({"return_url": return_url}),
}
client_login = "{login_url}?{return_url}".format(**login_vars)
Expand Down Expand Up @@ -893,6 +872,7 @@ def export_async(self, request, *args, **kwargs):
meta = request.query_params.get("meta")
data_id = request.query_params.get("data_id")
options = parse_request_export_options(request.query_params)
options["host"] = request.get_host()

options.update(
{
Expand Down
4 changes: 2 additions & 2 deletions onadata/apps/logger/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.files import File
from django.core.files.storage import get_storage_class
from django.http import (
Expand All @@ -34,6 +33,7 @@
from onadata.apps.logger.models.attachment import Attachment
from onadata.apps.logger.models.instance import Instance
from onadata.apps.logger.models.xform import XForm
from onadata.apps.api.tools import get_host_domain
from onadata.apps.main.models import MetaData, UserProfile
from onadata.libs.exceptions import EnketoError
from onadata.libs.utils.decorators import is_owner
Expand Down Expand Up @@ -91,7 +91,7 @@ def _html_submission_response(request, instance):
data = {}
data["username"] = instance.xform.user.username
data["id_string"] = instance.xform.id_string
data["domain"] = Site.objects.get(id=settings.SITE_ID).domain
data["domain"] = get_host_domain(request)

return render(request, "submission.html", data)

Expand Down
Loading

0 comments on commit dcfe4dd

Please sign in to comment.