diff --git a/futurex_openedx_extensions/dashboard/models.py b/futurex_openedx_extensions/dashboard/models.py deleted file mode 100644 index 6dc63ca1..00000000 --- a/futurex_openedx_extensions/dashboard/models.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Database models for dashboard. -""" diff --git a/futurex_openedx_extensions/dashboard/permissions.py b/futurex_openedx_extensions/dashboard/permissions.py new file mode 100644 index 00000000..452f3932 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/permissions.py @@ -0,0 +1,34 @@ +"""Permissions for the dashboard app""" +from rest_framework.permissions import BasePermission + +from futurex_openedx_extensions.dashboard.utils import get_accessible_tenants + + +class OnlyAccessibleTenants(BasePermission): + """Validate that the user has access to the tenant IDs provided in the request""" + def has_permission(self, request, view): + """Validation logic for the permission class""" + tenant_ids = request.query_params.get('tenant_ids') + if not tenant_ids: + # No tenant_ids provided + return False + user = request.user + accessible_tenants = get_accessible_tenants(user) + + # Extract tenant_ids from query parameters + if tenant_ids: + tenant_ids = [int(id) for id in tenant_ids.split(',')] + else: + # No tenant_ids provided + self.message = 'No tenant IDs provided.' + return False + + # Check which tenant_ids are not in the accessible tenants list + inaccessible_tenants = [str(tid) for tid in tenant_ids if tid not in accessible_tenants] + + if inaccessible_tenants: + # Generate a custom message with the inaccessible tenant IDs + self.message = f'User does not have access to tenant IDs: {", ".join(inaccessible_tenants)}.' + return False + + return True diff --git a/futurex_openedx_extensions/dashboard/statistics/__init__.py b/futurex_openedx_extensions/dashboard/statistics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/futurex_openedx_extensions/dashboard/statistics/learners.py b/futurex_openedx_extensions/dashboard/statistics/learners.py new file mode 100644 index 00000000..af12a739 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/statistics/learners.py @@ -0,0 +1,12 @@ +"""functions for getting statistics about learners""" +from __future__ import annotations +from typing import List + + +def get_learners_count(tenant_ids: List[int]) -> int: + """ + Get the count of learners in a tenant. + + :param tenant_ids: List of tenant IDs to get the count for + """ + diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py new file mode 100644 index 00000000..65a563ee --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views.py @@ -0,0 +1,15 @@ +"""Views for the dashboard app""" +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from futurex_openedx_extensions.helpers.mixins import TenantsCheckMixin + + +class TotalCountsView(TenantsCheckMixin): + """View to get the total count statistics""" + def get(self, request, *args, **kwargs): + """Get the total count statistics""" + + # Proceed with your view logic if the permission check passes + return Response({"message": "Access granted"}) diff --git a/futurex_openedx_extensions/helpers/apps.py b/futurex_openedx_extensions/helpers/apps.py new file mode 100644 index 00000000..cea81a09 --- /dev/null +++ b/futurex_openedx_extensions/helpers/apps.py @@ -0,0 +1,9 @@ +"""helpers Django application initialization""" + +from django.apps import AppConfig + + +class HelpersConfig(AppConfig): + """Configuration for the helpers Django application""" + + name = 'helpers' diff --git a/futurex_openedx_extensions/helpers/convertrers.py b/futurex_openedx_extensions/helpers/convertrers.py new file mode 100644 index 00000000..cda4dcc6 --- /dev/null +++ b/futurex_openedx_extensions/helpers/convertrers.py @@ -0,0 +1,10 @@ +"""Type conversion helpers""" +from __future__ import annotations +from typing import List + + +def ids_string_to_list(ids_string: str) -> List[int]: + """Convert a comma-separated string of ids to a list of integers""" + if not ids_string: + return [] + return [int(id_value.strip()) for id_value in ids_string.split(",") if id_value.strip()] diff --git a/futurex_openedx_extensions/helpers/mixins.py b/futurex_openedx_extensions/helpers/mixins.py new file mode 100644 index 00000000..aa59e3cb --- /dev/null +++ b/futurex_openedx_extensions/helpers/mixins.py @@ -0,0 +1,17 @@ +"""Base views to be used by other views""" + +from django.http import JsonResponse +from rest_framework.views import APIView +from rest_framework import status +from futurex_openedx_extensions.helpers.tenants import check_tenant_access + + +class TenantsCheckMixin(APIView): + """Mixin for views that require tenant information""" + def dispatch(self, request, *args, **kwargs): + """Check if the user has access to the tenant IDs provided""" + has_access, details = check_tenant_access(request.user, request.query_params.get('tenant_ids')) + + if not has_access: + return JsonResponse(details, status=status.HTTP_403_FORBIDDEN) + return super().dispatch(request, *args, **kwargs) diff --git a/futurex_openedx_extensions/helpers/tenants.py b/futurex_openedx_extensions/helpers/tenants.py new file mode 100644 index 00000000..618cb090 --- /dev/null +++ b/futurex_openedx_extensions/helpers/tenants.py @@ -0,0 +1,68 @@ +"""Tenant management helpers""" +from __future__ import annotations + +from django.contrib.auth import get_user_model +from eox_tenant.models import Route +from typing import List +from futurex_openedx_extensions.helpers.convertrers import ids_string_to_list +from django.db.models import Count + + + +def get_all_tenants() -> List[int]: + """ + TODO: Cache the result of this function + + Get all tenants in the system that have at least one organization associated with them + + :return: List of all tenant IDs + :rtype: List[int] + """ + return Route.objects.annotate( + orgs_count=Count('config__organizations') + ).filter( + orgs_count__gt=0 + ).values_list('id', flat=True) + + +def get_accessible_tenants(user: get_user_model()) -> List[int]: + """ + Get the tenants that the user has access to. + + :param user: The user to check. + :type user: get_user_model() + :return: List of accessible tenant IDs + :rtype: List[int] + """ + if user.is_superuser or user.is_staff: + return get_all_tenants() + return [] + + +def check_tenant_access(user: get_user_model(), tenant_ids_string: str) -> tuple[bool, dict]: + """ + Check if the user has access to the tenant IDs provided. + + :param user: The user to check. + :type user: get_user_model() + :param tenant_ids_string: Comma-separated string of tenant IDs + :type tenant_ids_string: str + :return: Tuple of a boolean indicating if the user has access and a dictionary with details if access is denied + """ + try: + tenant_ids = ids_string_to_list(tenant_ids_string) + except ValueError as e: + return False, {"reason": "Invalid tenant IDs provided", "details": { + "error": str(e) + }} + + accessible_tenants = get_accessible_tenants(user) + inaccessible_tenants = [t_id for t_id in tenant_ids if t_id not in accessible_tenants] + + if inaccessible_tenants: + return False, { + "reason": "user does not have access to these tenants", + "details": {"tenant_ids": inaccessible_tenants} + } + + return True, {} diff --git a/setup.py b/setup.py index 25bb3c47..7beaf092 100755 --- a/setup.py +++ b/setup.py @@ -138,6 +138,7 @@ def is_requirement(line): include=[ 'futurex_openedx_extensions', 'futurex_openedx_extensions.*', 'futurex_openedx_extensions.dashboard', 'futurex_openedx_extensions.dashboard.*', + 'futurex_openedx_extensions.helpers', 'futurex_openedx_extensions.helpers.*', ], exclude=["*tests"], ), @@ -160,6 +161,7 @@ def is_requirement(line): entry_points={ 'lms.djangoapp': [ 'dashboard = futurex_openedx_extensions.dashboard.apps:DashboardConfig', + 'helpers = futurex_openedx_extensions.helpers.apps:HelpersConfig', ], }, )