Skip to content

Commit bb4b6fd

Browse files
committed
feat(api): add update-by-domain action and enforce JSON field validation in TenantConfig (#237)
1 parent 085fcb5 commit bb4b6fd

File tree

6 files changed

+212
-13
lines changed

6 files changed

+212
-13
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## v7.1.1 - 2025-03-30
2+
3+
## [v7.1.1](https://github.com/eduNEXT/eox-tenant/compare/v7.0.0...v7.0.1) - (2025-03-30)
4+
5+
### Features
6+
- **API:** Added `update-by-domain` action in `TenantConfigViewSet` to allow updates using `route__domain` as a lookup field. This enhances flexibility for scenarios where only the domain is known.
7+
8+
### Improvements
9+
- **Validation:** Enforced dictionary validation for `lms_configs`, `studio_configs`, `theming_configs`, and `meta` fields in `TenantConfigSerializer`. These fields now strictly accept only dictionary values, preventing unexpected data types.
10+
- Fixes issue with login not being tenant-aware.
11+
112
## v7.0.0 - 2022-12-19
213

314
### [7.0.0](https://github.com/eduNEXT/eox-tenant/compare/v6.3.0...v7.0.0) (2022-12-19)

eox_tenant/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""
22
Init for eox-tenant.
33
"""
4-
__version__ = '7.0.0'
4+
__version__ = '7.1.1'

eox_tenant/api/v1/serializers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,30 @@ class Meta:
3232
model = TenantConfig
3333
fields = '__all__'
3434

35+
def validate_lms_configs(self, value):
36+
"""Ensure lms_configs is a dictionary."""
37+
if not isinstance(value, dict):
38+
raise serializers.ValidationError("lms_configs must be a dictionary.")
39+
return value
40+
41+
def validate_studio_configs(self, value):
42+
"""Ensure studio_configs is a dictionary."""
43+
if not isinstance(value, dict):
44+
raise serializers.ValidationError("studio_configs must be a dictionary.")
45+
return value
46+
47+
def validate_theming_configs(self, value):
48+
"""Ensure theming_configs is a dictionary."""
49+
if not isinstance(value, dict):
50+
raise serializers.ValidationError("theming_configs must be a dictionary.")
51+
return value
52+
53+
def validate_meta(self, value):
54+
"""Ensure meta is a dictionary."""
55+
if not isinstance(value, dict):
56+
raise serializers.ValidationError("meta must be a dictionary.")
57+
return value
58+
3559

3660
class RouteSerializer(serializers.ModelSerializer):
3761
"""Serializer class for Route model."""

eox_tenant/api/v1/tests/test_tenant_config.py

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from rest_framework import status
88
from rest_framework.test import APIClient, APITestCase
99

10-
from eox_tenant.models import TenantConfig
10+
from eox_tenant.models import Route, TenantConfig
1111

1212

13-
class TenantConfigAPITest(APITestCase):
13+
class TenantConfigAPITest(APITestCase): # pylint: disable=too-many-instance-attributes
1414
"""TenantConfig API TestCase."""
1515

1616
patch_permissions = patch('eox_tenant.api.v1.permissions.EoxTenantAPIPermission.has_permission', return_value=True)
@@ -31,7 +31,17 @@ def setUp(self):
3131
theming_configs={'key': 'value'},
3232
meta={'key': 'value'},
3333
)
34-
self.url_detail = '{url}{id}/'.format(url=self.url, id=self.tenant_config_example.pk)
34+
self.tenant_config_with_route = TenantConfig.objects.create(
35+
external_key='test_key_with_route',
36+
lms_configs={'PLATFORM_NAME': 'Old Name'},
37+
studio_configs={'key': 'value'},
38+
theming_configs={'key': 'value'},
39+
meta={'key': 'value'},
40+
)
41+
self.domain = 'site3.localhost'
42+
self.route = Route.objects.create(domain=self.domain, config=self.tenant_config_with_route)
43+
self.update_by_domain_url = f'{self.url}update-by-domain/'
44+
self.url_detail = f'{self.url}{self.tenant_config_example.pk}/'
3545

3646
@patch_permissions
3747
def test_get_tenant_configs(self, _):
@@ -50,8 +60,10 @@ def test_get_valid_single_tenant_config(self, _):
5060
@patch_permissions
5161
def test_create_tenant_config(self, _):
5262
"""Must create new TenantConfig."""
63+
tenant_config_objects_count = TenantConfig.objects.count()
64+
external_key = 'test_key_3'
5365
data = {
54-
'external_key': 'test_key',
66+
'external_key': external_key,
5567
'lms_configs': {'key': 'value'},
5668
'studio_configs': {'key': 'value'},
5769
'theming_configs': {'key': 'value'},
@@ -61,12 +73,11 @@ def test_create_tenant_config(self, _):
6173
response = self.client.post(self.url, data=data, format='json')
6274

6375
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
64-
self.assertEqual(TenantConfig.objects.count(), 2)
65-
self.assertEqual(TenantConfig.objects.get(pk=2).external_key, 'test_key')
66-
self.assertEqual(TenantConfig.objects.get(pk=2).lms_configs, {'key': 'value'})
67-
self.assertEqual(TenantConfig.objects.get(pk=2).studio_configs, {'key': 'value'})
68-
self.assertEqual(TenantConfig.objects.get(pk=2).theming_configs, {'key': 'value'})
69-
self.assertEqual(TenantConfig.objects.get(pk=2).meta, {'key': 'value'})
76+
self.assertEqual(TenantConfig.objects.count(), tenant_config_objects_count + 1)
77+
self.assertEqual(TenantConfig.objects.get(external_key=external_key).lms_configs, {'key': 'value'})
78+
self.assertEqual(TenantConfig.objects.get(external_key=external_key).studio_configs, {'key': 'value'})
79+
self.assertEqual(TenantConfig.objects.get(external_key=external_key).theming_configs, {'key': 'value'})
80+
self.assertEqual(TenantConfig.objects.get(external_key=external_key).meta, {'key': 'value'})
7081

7182
@patch_permissions
7283
def test_post_input_empty_data(self, _):
@@ -134,3 +145,102 @@ def test_delete_tenant_config(self, _):
134145
response = self.client.delete(self.url_detail)
135146

136147
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
148+
149+
@patch_permissions
150+
def test_update_tenant_config_by_domain_success(self, _):
151+
"""Must successfully update a TenantConfig using `route__domain`."""
152+
data = {
153+
"lms_configs": {
154+
"PLATFORM_NAME": "Updated Name"
155+
}
156+
}
157+
response = self.client.patch(f"{self.update_by_domain_url}?domain={self.domain}", data=data, format='json')
158+
159+
self.assertEqual(response.status_code, status.HTTP_200_OK)
160+
self.assertEqual(response.data["lms_configs"]["PLATFORM_NAME"], "Updated Name")
161+
162+
@patch_permissions
163+
def test_update_tenant_config_by_domain_missing_query_param(self, _):
164+
"""Must return 400 when domain query parameter is missing."""
165+
data = {
166+
"lms_configs": {
167+
"PLATFORM_NAME": "Updated Name"
168+
}
169+
}
170+
response = self.client.patch(self.update_by_domain_url, data=data, format='json')
171+
172+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
173+
self.assertEqual(response.data["error"], "The 'domain' query parameter is required.")
174+
175+
@patch_permissions
176+
def test_update_tenant_config_by_domain_not_found(self, _):
177+
"""Must return 404 when no TenantConfig is found for the given domain."""
178+
data = {
179+
"lms_configs": {
180+
"PLATFORM_NAME": "Updated Name"
181+
}
182+
}
183+
response = self.client.patch(f"{self.update_by_domain_url}?domain=unknown.localhost", data=data, format='json')
184+
185+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
186+
self.assertEqual(response.data["error"], "No TenantConfig found for domain 'unknown.localhost'.")
187+
188+
@patch_permissions
189+
def test_update_tenant_config_by_domain_empty_payload(self, _):
190+
"""Must ensure that if an empty payload is sent, nothing gets changed."""
191+
external_key = self.tenant_config_with_route.external_key
192+
lms_configs = self.tenant_config_with_route.lms_configs
193+
studio_configs = self.tenant_config_with_route.studio_configs
194+
theming_configs = self.tenant_config_with_route.theming_configs
195+
meta = self.tenant_config_with_route.meta
196+
197+
response = self.client.patch(f"{self.update_by_domain_url}?domain={self.domain}", data={}, format='json')
198+
self.tenant_config_with_route.refresh_from_db()
199+
200+
self.assertEqual(response.status_code, status.HTTP_200_OK)
201+
self.assertEqual(self.tenant_config_with_route.external_key, external_key)
202+
self.assertEqual(self.tenant_config_with_route.lms_configs, lms_configs)
203+
self.assertEqual(self.tenant_config_with_route.studio_configs, studio_configs)
204+
self.assertEqual(self.tenant_config_with_route.theming_configs, theming_configs)
205+
self.assertEqual(self.tenant_config_with_route.meta, meta)
206+
207+
@patch_permissions
208+
def test_update_tenant_config_by_domain_invalid_data(self, _):
209+
"""Must return 400 when the payload contains invalid data."""
210+
data = {
211+
"lms_configs": "Invalid structure" # Should be a dictionary
212+
}
213+
response = self.client.patch(f"{self.update_by_domain_url}?domain={self.domain}", data=data, format='json')
214+
215+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
216+
217+
@patch_permissions
218+
def test_partial_update_tenant_config_by_domain(self, _):
219+
"""Must allow partial updates without modifying other fields."""
220+
data = {
221+
"lms_configs": {
222+
"PLATFORM_NAME": "New Partial Update"
223+
}
224+
}
225+
response = self.client.patch(f"{self.update_by_domain_url}?domain={self.domain}", data=data, format='json')
226+
print(100 * "#")
227+
print(response.content)
228+
229+
self.assertEqual(response.status_code, status.HTTP_200_OK)
230+
self.tenant_config_with_route.refresh_from_db()
231+
self.assertEqual(self.tenant_config_with_route.lms_configs["PLATFORM_NAME"], "New Partial Update")
232+
233+
@patch_permissions
234+
def test_update_tenant_config_by_domain_preserves_other_fields(self, _):
235+
"""Ensure updating one field does not erase other fields."""
236+
data = {
237+
"lms_configs": {
238+
"PLATFORM_NAME": "Updated Platform Name"
239+
}
240+
}
241+
response = self.client.patch(f"{self.update_by_domain_url}?domain={self.domain}", data=data, format='json')
242+
243+
self.assertEqual(response.status_code, status.HTTP_200_OK)
244+
self.tenant_config_with_route.refresh_from_db()
245+
self.assertEqual(self.tenant_config_with_route.lms_configs["PLATFORM_NAME"], "Updated Platform Name")
246+
self.assertEqual(self.tenant_config_with_route.studio_configs, {"key": "value"})

eox_tenant/api/v1/viewsets.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44
import logging
55

6-
from rest_framework import viewsets
6+
from rest_framework import status, viewsets
77
from rest_framework.authentication import SessionAuthentication
8+
from rest_framework.decorators import action
89
from rest_framework.parsers import JSONParser
10+
from rest_framework.response import Response
911

1012
from eox_tenant.api.v1.permissions import EoxTenantAPIPermission
1113
from eox_tenant.api.v1.serializers import MicrositeSerializer, RouteSerializer, TenantConfigSerializer
@@ -446,6 +448,58 @@ class TenantConfigViewSet(AlternativeFieldLookupMixin, viewsets.ModelViewSet):
446448
alternative_lookup_field = 'external_key'
447449
queryset = TenantConfig.objects.all()
448450

451+
@action(detail=False, methods=['patch'], url_path='update-by-domain')
452+
def update_by_domain(self, request):
453+
"""
454+
Custom endpoint to update a TenantConfig using `route__domain`.
455+
456+
**Request Format:**
457+
```
458+
PATCH /eox-tenant/api/v1/configs/update-by-domain/?domain=site3.localhost
459+
Content-Type: application/json
460+
461+
{
462+
"lms_configs": {
463+
"PLATFORM_NAME": "Updated Name"
464+
}
465+
}
466+
```
467+
468+
**Response Format:**
469+
```
470+
{
471+
"id": 2,
472+
"external_key": "xAer5z6FEbW",
473+
"lms_configs": {
474+
"PLATFORM_NAME": "Updated Name"
475+
},
476+
...
477+
}
478+
```
479+
"""
480+
domain = request.query_params.get("domain")
481+
482+
if not domain:
483+
return Response(
484+
{"error": "The 'domain' query parameter is required."},
485+
status=status.HTTP_400_BAD_REQUEST,
486+
)
487+
488+
try:
489+
tenant_config = TenantConfig.objects.get(route__domain=domain)
490+
except TenantConfig.DoesNotExist: # pylint: disable=no-member
491+
return Response(
492+
{"error": f"No TenantConfig found for domain '{domain}'."},
493+
status=status.HTTP_404_NOT_FOUND,
494+
)
495+
496+
serializer = self.get_serializer(tenant_config, data=request.data, partial=True)
497+
if serializer.is_valid():
498+
serializer.save()
499+
return Response(serializer.data, status=status.HTTP_200_OK)
500+
501+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
502+
449503

450504
class RouteViewSet(viewsets.ModelViewSet):
451505
"""RouteViewSet that allows the basic API actions."""

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 7.0.0
2+
current_version = 7.1.1
33
commit = False
44
tag = False
55

0 commit comments

Comments
 (0)