Skip to content

Commit

Permalink
feat: theme config drfat api implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
tehreem-sadat committed Feb 27, 2025
1 parent 045380f commit 7672fa3
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 37 deletions.
29 changes: 11 additions & 18 deletions futurex_openedx_extensions/dashboard/docs_src.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ def get_optional_parameter(path: str) -> Any:
str,
'The username of the staff user to retrieve information for.',
),
'tenant_id': path_parameter(
'tenant_id',
int,
'The id of the tenant to retrieve or update config for.',
),
}

repeated_descriptions = {
Expand Down Expand Up @@ -1421,12 +1426,7 @@ def get_optional_parameter(path: str) -> Any:
'description': 'Get the current draft of theme configuration for a given tenant. The caller must have '
'staff access.\n\n**Note:** This API is just mock API with dummy data and not implemented yet.',
'parameters': [
query_parameter(
'tenant_ids',
str,
'Tenant ids to retrieve the configuration for. \n '
'**Note:** The caller must provide single tenant id to access the configuration.',
),
common_path_parameters['tenant_id']
],
'responses': responses(
success_description='The response is list of updated fields with published and draft values along with '
Expand Down Expand Up @@ -1513,14 +1513,12 @@ def get_optional_parameter(path: str) -> Any:
'description': 'Update draft of theme configuration for a given tenant otherwise create new draft with '
'updated values if draft does not exist.\n'
'\n**Note:** This API is just mock API with dummy data and not implemented yet.',
'parameters': [
common_path_parameters['tenant_id'],
],
'body': openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tenant_id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description='The tenant ID to update the config for.',
example=1,
),
'key': openapi.Schema(
type=openapi.TYPE_STRING,
description='Config field name value is updated for.',
Expand All @@ -1544,7 +1542,7 @@ def get_optional_parameter(path: str) -> Any:
example=0,
),
},
required=['tenant_id', 'key', 'current_value', 'new_value']
required=['key', 'current_value']
),
'responses': responses(
overrides={
Expand Down Expand Up @@ -1585,12 +1583,7 @@ def get_optional_parameter(path: str) -> Any:
'description': 'Delete/discard draft changes of theme config for a given tenant.\n'
'\n**Note:** This API is just mock API with dummy data and not implemented yet.',
'parameters': [
query_parameter(
'tenant_ids',
str,
'Tenant ids to retrieve the configuration for. \n '
'**Note:** The caller must provide single tenant id to delete the configuration.',
),
common_path_parameters['tenant_id'],
],
'responses': responses(
overrides={
Expand Down
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
re_path(r'^api/fx/version/v1/info/$', views.VersionInfoView.as_view(), name='version-info'),

re_path(r'^api/fx/config/v1/editable/$', views.ConfigEditableInfoView.as_view(), name='config-editable-info'),
re_path(r'^api/fx/config/v1/draft/$', views.ThemeConfigDraftView.as_view(), name='theme-config-draft'),
re_path(r'^api/fx/config/v1/draft/(?P<tenant_id>\d+)/$', views.ThemeConfigDraftView.as_view(), name='theme-config-draft'),
re_path(r'^api/fx/config/v1/publish/$', views.ThemeConfigPublishView.as_view(), name='theme-config-publish'),
re_path(r'^api/fx/config/v1/values/$', views.ThemeConfigRetrieveView.as_view(), name='theme-config-values'),
re_path(r'^api/fx/config/v1/tenant/$', views.ThemeConfigTenantView.as_view(), name='theme-config-tenant'),
Expand Down
84 changes: 66 additions & 18 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import re
import json
import hashlib
from datetime import date, datetime, timedelta
from typing import Any, Dict
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
Expand All @@ -19,6 +21,7 @@
from django.shortcuts import get_object_or_404
from django_filters.rest_framework import DjangoFilterBackend
from edx_api_doc_tools import exclude_schema_for
from eox_tenant.models import TenantConfig
from rest_framework import status as http_status
from rest_framework import viewsets
from rest_framework.exceptions import ParseError
Expand Down Expand Up @@ -83,6 +86,11 @@
create_new_tenant_config,
get_excluded_tenant_ids,
get_tenants_info,
get_draft_tenant_config,
get_all_tenants_info,
get_tenant_config_value,
update_draft_tenant_config,
delete_draft_tenant_config
)
from futurex_openedx_extensions.helpers.users import get_user_by_key

Expand Down Expand Up @@ -1223,33 +1231,73 @@ class ThemeConfigDraftView(FXViewRoleInfoMixin, APIView):
permission_classes = [FXHasTenantCourseAccess]
fx_view_name = 'theme_config_draft'
fx_allowed_write_methods = ['PUT', 'DELETE']
fx_view_description = 'api/fx/config/v1/draft/: draft theme config APIs'
fx_view_description = 'api/fx/config/v1/draft/<tenant_id>: draft theme config APIs'

def get(self, request: Any) -> Response | JsonResponse: # pylint: disable=no-self-use
def get(self, request: Any, tenant_id: int) -> Response | JsonResponse: # pylint: disable=no-self-use
"""Get draft config"""

updated_fields = get_draft_tenant_config(int(tenant_id))
dict_str = json.dumps(updated_fields, sort_keys=True, separators=(',', ':')) if updated_fields else ''
return JsonResponse({
'updated_fields': {
'platform_name': {
'published_value': 'my platform name',
'draft_value': 'My Awesome Platform'
},
'primary_color': {
'published_value': '#ff0000',
'draft_value': '#ffff00'
},
},
'draft_hash': 'ajsd90a8su9a8u9a8sdyf0a9sdhy0asdjgasdgkjdsfgj'
'updated_fields': updated_fields,
'draft_hash': hashlib.sha256(dict_str.encode()).hexdigest() if dict_str else ''
})

def put(self, request: Any) -> Response: # pylint: disable=no-self-use
def put(self, request: Any, tenant_id: int) -> Response:
"""Update draft config"""
data = request.data

return Response(status=http_status.HTTP_204_NO_CONTENT)
try:
if not isinstance(data['key'], str):
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message="Key name must be a string."
)

def delete(self, request: Any) -> Response: # pylint: disable=no-self-use
"""Delete draft config"""
key_access_info = ConfigAccessControl.objects.get(key_name=data['key'])
if not key_access_info.writable:
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message=f'Config Key: ({key}) is not writable.'
)

new_value = data.get("new_value")
reset = data.get("reset", False)
if reset is False and new_value is None:
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message="Provide either new_value or reset."
)
if new_value and type(new_value) != type(data['current_value']):
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message="New value type must match current value."
)

if reset and not isinstance(reset, bool):
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message="Reset must be a boolean value."
)

update_draft_tenant_config(int(tenant_id), key_access_info, data['current_value'], new_value, reset)
return Response(status=http_status.HTTP_204_NO_CONTENT)

except KeyError as exc:
return Response(
error_details_to_dictionary(reason=f'Missing required parameter: {exc}'),
status=http_status.HTTP_400_BAD_REQUEST
)
except FXCodedException as exc:
return Response(
error_details_to_dictionary(reason=f'({exc.code}) {str(exc)}'),
status=http_status.HTTP_400_BAD_REQUEST
)
except ConfigAccessControl.DoesNotExist as exc:
return Response(
error_details_to_dictionary(
reason = f'Inavlid key, unable to find key: ({data["key"]}) in config access control'
),
status=http_status.HTTP_400_BAD_REQUEST
)

def delete(self, request: Any, tenant_id) -> Response: # pylint: disable=no-self-use
"""Delete draft config"""
delete_draft_tenant_config(int(tenant_id))
return Response(status=http_status.HTTP_204_NO_CONTENT)


Expand Down
136 changes: 136 additions & 0 deletions futurex_openedx_extensions/helpers/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import json
from django.core.cache import cache
from typing import Any, Dict, List
from urllib.parse import urlparse

Expand Down Expand Up @@ -121,6 +122,9 @@ def get_all_tenants_info() -> Dict[str, str | dict | List[int]]:
'logo_image_url': (tenant['lms_configs'].get('logo_image_url') or '').strip(),
} for tenant in info
},
'config': {
tenant['id']: tenant['lms_configs'] for tenant in info
},
'tenant_by_site': {
tenant['route__domain']: tenant['id'] for tenant in info
},
Expand Down Expand Up @@ -369,3 +373,135 @@ def create_new_tenant_config(sub_domain: str, platform_name: str) -> TenantConfi
)
invalidate_cache()
return tenant_config


def get_tenant_config_value(config, path, published_only=False):
"""
Retrieves the configuration value based on the path.
Prioritizes draft values unless `published_only` is True.
:param config: The configuration dictionary.
:param path: The comma-separated path to the desired value.
:param published_only: If True, fetches only the published value.
:return: The configuration value or None if not found.
"""
def get_nested_value(config: dict, path: str):
"""
Retrieves the value from a nested dictionary based on a comma-separated path.
"""
keys = [key.strip() for key in path.split(',')]
for key in keys:
if not isinstance(config, dict) or key not in config:
return None
config = config[key]
return config

if not config:
return None

if published_only:
return get_nested_value(config, path)

draft_value = get_nested_value(config.get('config_draft'), path)
return draft_value or get_nested_value(config, path)


def get_draft_tenant_config(tenant_id: int) -> dict:
"""
This function fetches the configuration for the specified tenant and returns the
fields that differ between the published and draft configurations.
:param tenant_id: The ID of the tenant whose draft configuration are to be retrieved.
:type tenant_id: int
:return: A dictionary containing updated fields with published and draft values.
:rtype: dict
"""

def compare_fields(root: dict, draft: dict, parent_key=""):
for key, draft_value in draft.items():
full_key = f"{parent_key}.{key}" if parent_key else key
published_value = root.get(key) if isinstance(root, dict) else None

if isinstance(draft_value, dict):
compare_fields(published_value or {}, draft_value, full_key)
elif published_value != draft_value:
updated_fields[key] = {
"published_value": published_value,
"draft_value": draft_value,
}
config = get_all_tenants_info()['config'].get(tenant_id, {})
updated_fields = {}
compare_fields(config, config.get("config_draft", {}))
return updated_fields


def update_draft_tenant_config(tenant_id: int, key_access_info,current_value, new_value, reset=False) -> dict:
"""
Updates the draft configuration for the specified tenant with the new value.
If reset is True, it resets the field to its published value or removes it.
:param tenant_config: The configuration for the tenant, containing both published and draft data.
:type tenant_config: dict
:param key_access_info: A dictionary or path representing the nested key in the configuration.
:type key_access_info: dict or list of strings
:param new_value: The new value to update the field with.
:type new_value: str
:param reset: A boolean indicating whether to reset the field to its published value. Defaults to False.
:type reset: bool
:return: A dictionary with the updated fields and their new values.
:rtype: dict
"""
config = get_all_tenants_info()['config'].get(tenant_id)
if not config:
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT,
message=f'Unable to find config for tenant: {tenant_id}'
)

stored_value = get_tenant_config_value(config, key_access_info.path)
if stored_value is None:
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT,
message='Invalid path for key: ({key_access_info.key}), path: ({key_access_info.path}) '
'as it does not exist in tenant config for tenant: {tenant_id}.'
)
if stored_value != current_value:
raise FXCodedException(
code=FXExceptionCodes.INVALID_INPUT, message="Current value mismatched with last stored value."
)

draft_config = config.get('config_draft', {})
keys = [key.strip() for key in key_access_info.path.split(',')]
current = draft_config
for key in keys[:-1]:
current = current.setdefault(key, {})

if reset:
current.pop(keys[-1], None)
else:
current[keys[-1]] = new_value

tenant = TenantConfig.objects.get(id=tenant_id)
tenant.lms_configs['config_draft'] = draft_config
tenant.save()
invalidate_cache()


def delete_draft_tenant_config(tenant_id: int) -> None:
"""
Deletes the draft configuration for the specified tenant.
:param tenant_id: The ID of the tenant whose draft config needs to be deleted.
:type tenant_id: int
"""
import pdb; pdb.set_trace()
try:
tenant = TenantConfig.objects.get(id=tenant_id)
tenant.lms_configs['config_draft'] = {}
tenant.save()
invalidate_cache()
except TenantConfig.DoesNotExist:
raise FXCodedException(
code=FXExceptionCodes.TENANT_NOT_FOUND,
message=f"Unable to find tenant with id: {tenant_id}"
)

0 comments on commit 7672fa3

Please sign in to comment.