Skip to content

Commit 5b1fed8

Browse files
committed
feat(api): add update-by-domain action and enforce JSON field validation in TenantConfig
1 parent ffef2fe commit 5b1fed8

File tree

6 files changed

+208
-12
lines changed

6 files changed

+208
-12
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [v13.1.0](https://github.com/eduNEXT/eox-tenant/compare/v13.0.0...v13.1.0) - (2025-02-27)
9+
10+
### Features
11+
- **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.
12+
13+
### Improvements
14+
- **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.
15+
816
## [v13.0.0](https://github.com/eduNEXT/eox-tenant/compare/v12.1.0...v13.0.0) - (2025-01-20)
917

1018
#### Features

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__ = '13.0.0'
4+
__version__ = '13.1.0'

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: 119 additions & 9 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,6 +31,16 @@ def setUp(self):
3131
theming_configs={'key': 'value'},
3232
meta={'key': 'value'},
3333
)
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/'
3444
self.url_detail = f'{self.url}{self.tenant_config_example.pk}/'
3545

3646
@patch_permissions
@@ -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
@@ -4,9 +4,11 @@
44
import logging
55

66
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
7-
from rest_framework import viewsets
7+
from rest_framework import status, viewsets
88
from rest_framework.authentication import SessionAuthentication
9+
from rest_framework.decorators import action
910
from rest_framework.parsers import JSONParser
11+
from rest_framework.response import Response
1012

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

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

451505
class RouteViewSet(viewsets.ModelViewSet):
452506
"""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 = 13.0.0
2+
current_version = 13.1.0
33
commit = False
44
tag = False
55

0 commit comments

Comments
 (0)