diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py deleted file mode 100644 index 0f2666fe..00000000 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ /dev/null @@ -1,1361 +0,0 @@ -"""Serializers for the dashboard details API.""" -# pylint: disable=too-many-lines -from __future__ import annotations - -import logging -import os -import re -from typing import Any, Dict, List, Tuple - -from common.djangoapps.student.auth import add_users -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole -from django.conf import settings -from django.contrib.auth import get_user_model -from django.utils.timezone import now -from eox_nelp.course_experience.models import FeedbackCourse -from eox_tenant.models import TenantConfig -from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary -from lms.djangoapps.grades.api import CourseGradeFactory -from lms.djangoapps.grades.context import grading_context_for_course -from lms.djangoapps.grades.models import PersistentSubsectionGrade -from opaque_keys.edx.locator import CourseLocator -from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration -from openedx.core.djangoapps.django_comment_common.models import assign_default_role -from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles -from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer -from openedx.core.lib.courses import get_course_by_id -from organizations.api import add_organization_course, ensure_organization -from rest_framework import serializers -from rest_framework.fields import empty -from social_django.models import UserSocialAuth -from xmodule.course_block import CourseFields -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import DuplicateCourseError - -from futurex_openedx_extensions.dashboard.custom_serializers import ( - ModelSerializerOptionalFields, - SerializerOptionalMethodField, -) -from futurex_openedx_extensions.helpers.certificates import get_certificate_date, get_certificate_url -from futurex_openedx_extensions.helpers.constants import ( - ALLOWED_FILE_EXTENSIONS, - COURSE_ACCESS_ROLES_GLOBAL, - COURSE_STATUS_SELF_PREFIX, - COURSE_STATUSES, -) -from futurex_openedx_extensions.helpers.converters import ( - DEFAULT_DATETIME_FORMAT, - dt_to_str, - relative_url_to_absolute_url, -) -from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes -from futurex_openedx_extensions.helpers.export_csv import get_exported_file_url -from futurex_openedx_extensions.helpers.extractors import ( - extract_arabic_name_from_user, - extract_full_name_from_user, - import_from_path, -) -from futurex_openedx_extensions.helpers.models import DataExportTask, TenantAsset -from futurex_openedx_extensions.helpers.roles import ( - RoleType, - get_course_access_roles_queryset, - get_user_course_access_roles, -) -from futurex_openedx_extensions.helpers.tenants import ( - get_all_tenants_info, - get_org_to_tenant_map, - get_sso_sites, - get_tenants_by_org, - set_request_domain_by_org, -) - -logger = logging.getLogger(__name__) - - -class DataExportTaskSerializer(ModelSerializerOptionalFields): - """Serializer for Data Export Task""" - download_url = SerializerOptionalMethodField(field_tags=['download_url']) - - class Meta: - model = DataExportTask - fields = [ - 'id', - 'user_id', - 'tenant_id', - 'status', - 'progress', - 'view_name', - 'related_id', - 'filename', - 'notes', - 'created_at', - 'started_at', - 'completed_at', - 'download_url', - 'error_message', - ] - read_only_fields = [ - field.name for field in DataExportTask._meta.fields if field.name not in ['notes'] - ] - - def validate_notes(self: Any, value: str) -> str: # pylint: disable=no-self-use - """Sanitize the notes field and escape HTML tags.""" - value = re.sub(r'<', '<', value) - value = re.sub(r'>', '>', value) - return value - - def get_download_url(self, obj: DataExportTask) -> Any: # pylint: disable=no-self-use - """Return download url.""" - return get_exported_file_url(obj) - - -class LearnerBasicDetailsSerializer(ModelSerializerOptionalFields): - """Serializer for learner's basic details.""" - user_id = serializers.SerializerMethodField(help_text='User ID in edx-platform') - full_name = serializers.SerializerMethodField(help_text='Full name of the user') - alternative_full_name = serializers.SerializerMethodField(help_text='Arabic name (if available)') - username = serializers.SerializerMethodField(help_text='Username of the user in edx-platform') - national_id = serializers.SerializerMethodField(help_text='National ID of the user (if available)') - email = serializers.SerializerMethodField(help_text='Email of the user in edx-platform') - mobile_no = serializers.SerializerMethodField(help_text='Mobile number of the user (if available)') - year_of_birth = serializers.SerializerMethodField(help_text='Year of birth of the user (if available)') - gender = serializers.SerializerMethodField(help_text='Gender code of the user (if available)') - gender_display = serializers.SerializerMethodField(help_text='Gender of the user (if available)') - date_joined = serializers.SerializerMethodField( - help_text='Date when the user was registered in the platform regardless of which tenant', - ) - last_login = serializers.SerializerMethodField(help_text='Date when the user last logged in') - - class Meta: - model = get_user_model() - fields = [ - 'user_id', - 'full_name', - 'alternative_full_name', - 'username', - 'national_id', - 'email', - 'mobile_no', - 'year_of_birth', - 'gender', - 'gender_display', - 'date_joined', - 'last_login', - ] - - def _get_user(self, obj: Any = None) -> get_user_model | None: # pylint: disable=no-self-use - """ - Retrieve the associated user for the given object. - - This method can be overridden in child classes to provide a different - implementation for accessing the user, depending on how the user is - related to the object (e.g., `obj.user`, `obj.profile.user`, etc.). - """ - return obj - - def _get_profile_field(self: Any, obj: get_user_model, field_name: str) -> Any: - """Get the profile field value.""" - user = self._get_user(obj) - return getattr(user.profile, field_name) if hasattr(user, 'profile') and user.profile else None - - def _get_extra_field(self: Any, obj: get_user_model, field_name: str) -> Any: - """Get the extra field value.""" - user = self._get_user(obj) - return getattr(user.extrainfo, field_name) if hasattr(user, 'extrainfo') and user.extrainfo else None - - def get_user_id(self, obj: get_user_model) -> int: - """Return user ID.""" - return self._get_user(obj).id # type: ignore - - def get_email(self, obj: get_user_model) -> str: - """Return user ID.""" - return self._get_user(obj).email # type: ignore - - def get_username(self, obj: get_user_model) -> str: - """Return user ID.""" - return self._get_user(obj).username # type: ignore - - def get_date_joined(self, obj: Any) -> str | None: - date_joined = self._get_user(obj).date_joined # type: ignore - return dt_to_str(date_joined) - - def get_last_login(self, obj: Any) -> str | None: - last_login = self._get_user(obj).last_login # type: ignore - return dt_to_str(last_login) - - def get_national_id(self, obj: get_user_model) -> Any: - """Return national ID.""" - return self._get_extra_field(obj, 'national_id') - - def get_full_name(self, obj: get_user_model) -> Any: - """Return full name.""" - return extract_full_name_from_user(self._get_user(obj)) - - def get_alternative_full_name(self, obj: get_user_model) -> Any: - """Return alternative full name.""" - return ( - extract_arabic_name_from_user(self._get_user(obj)) or - extract_full_name_from_user(self._get_user(obj), alternative=True) - ) - - def get_mobile_no(self, obj: get_user_model) -> Any: - """Return mobile number.""" - return self._get_profile_field(obj, 'phone_number') - - def get_gender(self, obj: get_user_model) -> Any: - """Return gender.""" - return self._get_profile_field(obj, 'gender') - - def get_gender_display(self, obj: get_user_model) -> Any: - """Return readable text for gender""" - return self._get_profile_field(obj, 'gender_display') - - def get_year_of_birth(self, obj: get_user_model) -> Any: - """Return year of birth.""" - return self._get_profile_field(obj, 'year_of_birth') - - -class CourseScoreAndCertificateSerializer(ModelSerializerOptionalFields): - """ - Course Score and Certificate Details Serializer - - Handles data for multiple courses, but exam_scores is included only for a single course when course_id is - provided in the context. Otherwise, exam_scores is excluded, even if requested in requested_optional_field_tags. - """ - exam_scores = SerializerOptionalMethodField(field_tags=['exam_scores', 'csv_export']) - certificate_available = serializers.BooleanField() - course_score = serializers.FloatField() - active_in_course = serializers.BooleanField() - progress = SerializerOptionalMethodField(field_tags=['progress', 'csv_export']) - certificate_url = SerializerOptionalMethodField(field_tags=['certificate_url', 'csv_export']) - certificate_date = SerializerOptionalMethodField(field_tags=['certificate_date', 'csv_export']) - - class Meta: - fields = [ - 'certificate_available', - 'course_score', - 'active_in_course', - 'progress', - 'certificate_url', - 'certificate_date', - 'exam_scores', - ] - - def __init__(self, *args: Any, **kwargs: Any): - """Initialize the serializer.""" - super().__init__(*args, **kwargs) - self._is_exam_name_in_header = self.context.get('omit_subsection_name', '0') != '1' - self._grading_info: Dict[str, Any] = {} - self._subsection_locations: Dict[str, Any] = {} - - if self.context.get('course_id'): - self.collect_grading_info() - - def collect_grading_info(self) -> None: - """ - Collect the grading info. - """ - course_id = CourseLocator.from_string(self.context.get('course_id')) - self._grading_info = {} - self._subsection_locations = {} - if not self.is_optional_field_requested('exam_scores'): - return - - grading_context = grading_context_for_course(get_course_by_id(course_id)) - index = 0 - for assignment_type_name, subsection_infos in grading_context['all_graded_subsections_by_type'].items(): - for subsection_index, subsection_info in enumerate(subsection_infos, start=1): - header_enum = f' {subsection_index}' if len(subsection_infos) > 1 else '' - header_name = f'{assignment_type_name}{header_enum}' - if self.is_exam_name_in_header: - header_name += f': {subsection_info["subsection_block"].display_name}' - - self._grading_info[str(index)] = { - 'header_name': header_name, - 'location': str(subsection_info['subsection_block'].location), - } - self._subsection_locations[str(subsection_info['subsection_block'].location)] = str(index) - index += 1 - - @property - def is_exam_name_in_header(self) -> bool: - """Check if the exam name is needed in the header.""" - return self._is_exam_name_in_header - - @property - def grading_info(self) -> Dict[str, Any]: - """Get the grading info.""" - return self._grading_info - - @property - def subsection_locations(self) -> Dict[str, Any]: - """Get the subsection locations.""" - return self._subsection_locations - - def _get_course_id(self, obj: Any = None) -> Any: - """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" - raise NotImplementedError('Child class must implement _get_user method.') - - def _get_user(self, obj: Any = None) -> Any: - """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" - raise NotImplementedError('Child class must implement _get_course_id method.') - - def get_certificate_url(self, obj: Any) -> Any: - """Return the certificate URL.""" - return get_certificate_url( - self.context.get('request'), self._get_user(obj), self._get_course_id(obj) - ) - - def get_certificate_date(self, obj: Any) -> Any: - """Return the certificate Date.""" - return dt_to_str(get_certificate_date( - self._get_user(obj), self._get_course_id(obj) - )) - - def get_progress(self, obj: Any) -> Any: - """Return the certificate URL.""" - progress_info = get_course_blocks_completion_summary( - self._get_course_id(obj), self._get_user(obj) - ) - total = progress_info['complete_count'] + progress_info['incomplete_count'] + progress_info['locked_count'] - return round(progress_info['complete_count'] / total, 4) if total else 0.0 - - def get_exam_scores(self, obj: Any) -> Dict[str, Tuple[float, float] | None]: - """Return exam scores.""" - result: Dict[str, Tuple[float, float] | None] = {__index: None for __index in self.grading_info} - grades = PersistentSubsectionGrade.objects.filter( - user_id=self._get_user(obj).id, - course_id=self._get_course_id(obj), - usage_key__in=self.subsection_locations.keys(), - first_attempted__isnull=False, - ).values('usage_key', 'earned_all', 'possible_all') - - for grade in grades: - result[self.subsection_locations[str(grade['usage_key'])]] = (grade['earned_all'], grade['possible_all']) - - return result - - def to_representation(self, instance: Any) -> Any: - """Return the representation of the instance.""" - def _extract_exam_scores(representation_item: dict[str, Any]) -> None: - exam_scores = representation_item.pop('exam_scores', {}) - for index, score in exam_scores.items(): - earned_key = f'earned - {self.grading_info[index]["header_name"]}' - possible_key = f'possible - {self.grading_info[index]["header_name"]}' - representation_item[earned_key] = score[0] if score else 'no attempt' - representation_item[possible_key] = score[1] if score else 'no attempt' - - representation = super().to_representation(instance) - - _extract_exam_scores(representation) - - return representation - - -class LearnerDetailsSerializer(LearnerBasicDetailsSerializer): - """Serializer for learner details.""" - enrolled_courses_count = serializers.SerializerMethodField(help_text='Number of courses the user is enrolled in') - certificates_count = serializers.SerializerMethodField(help_text='Number of certificates the user has earned') - - class Meta: - model = get_user_model() - fields = LearnerBasicDetailsSerializer.Meta.fields + [ - 'enrolled_courses_count', - 'certificates_count', - ] - - def get_certificates_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use - """Return certificates count.""" - return obj.certificates_count - - def get_enrolled_courses_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use - """Return enrolled courses count.""" - return obj.courses_count - - -class LearnerDetailsForCourseSerializer( - LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer -): # pylint: disable=too-many-ancestors - """Serializer for learner details for a course.""" - - class Meta: - model = get_user_model() - fields = LearnerBasicDetailsSerializer.Meta.fields + CourseScoreAndCertificateSerializer.Meta.fields - - def _get_course_id(self, obj: Any = None) -> CourseLocator: - """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" - return CourseLocator.from_string(self.context.get('course_id')) - - def _get_user(self, obj: Any = None) -> get_user_model: - """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" - return obj - - -class LearnerEnrollmentSerializer( - LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer -): # pylint: disable=too-many-ancestors - """Serializer for learner enrollments""" - course_id = serializers.SerializerMethodField() - sso_external_id = SerializerOptionalMethodField(field_tags=['sso_external_id', 'csv_export']) - - class Meta: - model = CourseEnrollment - fields = ( - LearnerBasicDetailsSerializer.Meta.fields + - CourseScoreAndCertificateSerializer.Meta.fields + - ['course_id', 'sso_external_id'] - ) - - def _get_course_id(self, obj: Any = None) -> CourseLocator | None: - """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" - return obj.course_id if obj else None - - def _get_user(self, obj: Any = None) -> get_user_model | None: - """ - Get the User. Its helper method required for CourseScoreAndCertificateSerializer. It also - plays important role for LearnerBasicDetailsSerializer - """ - return obj.user if obj else None - - def get_course_id(self, obj: Any) -> str: - """Get course id""" - return str(self._get_course_id(obj)) - - @staticmethod - def get_sso_site_info(obj: Any) -> List[Dict[str, Any]]: - """Get SSO information of the tenant's site related to the course""" - course_tenants = get_org_to_tenant_map().get(obj.course_id.org.lower(), []) - for tenant_id in course_tenants: - sso_site_info = get_sso_sites().get(get_all_tenants_info()['sites'][tenant_id]) - if sso_site_info: - return sso_site_info - - return [] - - def get_sso_external_id(self, obj: Any) -> str: - """Get the SSO external ID from social auth extra_data.""" - result = '' - - sso_site_info = self.get_sso_site_info(obj) - if not sso_site_info: - return result - - social_auth_records = UserSocialAuth.objects.filter(user=obj.user, provider='tpa-saml') - user_auth_by_slug = {} - for record in social_auth_records: - if record.uid.count(':') == 1: - sso_slug, _ = record.uid.split(':') - user_auth_by_slug[sso_slug] = record - - if not user_auth_by_slug: - return result - - for entity_id, sso_info in settings.FX_SSO_INFO.items(): - if not sso_info.get('external_id_field') or not sso_info.get('external_id_extractor'): - logger.warning( - 'Bad (external_id_field) or (external_id_extractor) settings for Entity ID (%s)', entity_id, - ) - continue - - for sso_links in sso_site_info: - if entity_id == sso_links['entity_id']: - user_auth_record = user_auth_by_slug.get(sso_links['slug']) - if not user_auth_record: - continue - - external_id_value = user_auth_record.extra_data.get(sso_info['external_id_field']) - if external_id_value: - try: - external_id_extractor = import_from_path(sso_info['external_id_extractor']) - except Exception as exc: - raise FXCodedException( - code=FXExceptionCodes.BAD_CONFIGURATION_EXTERNAL_ID_EXTRACTOR, - message=f'Bad configuration: FX_SSO_INFO.{entity_id}.external_id_extractor. {str(exc)}' - ) from exc - - try: - result = str(external_id_extractor(external_id_value) or '') - except Exception as exc: - logger.warning( - 'SSO External ID extraction raised and error for user %s: %s', obj.user.username, exc, - ) - break - - return result - - -class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer): # pylint: disable=too-many-ancestors - """Serializer for extended learner details.""" - city = serializers.SerializerMethodField() - bio = serializers.SerializerMethodField() - level_of_education = serializers.SerializerMethodField() - social_links = serializers.SerializerMethodField() - image = serializers.SerializerMethodField() - profile_link = serializers.SerializerMethodField() - - class Meta: - model = get_user_model() - fields = LearnerDetailsSerializer.Meta.fields + [ - 'city', - 'bio', - 'level_of_education', - 'social_links', - 'image', - 'profile_link', - ] - - def get_city(self, obj: get_user_model) -> Any: - """Return city.""" - return self._get_profile_field(obj, 'city') - - def get_bio(self, obj: get_user_model) -> Any: - """Return bio.""" - return self._get_profile_field(obj, 'bio') - - def get_level_of_education(self, obj: get_user_model) -> Any: - """Return level of education.""" - return self._get_profile_field(obj, 'level_of_education_display') - - def get_social_links(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use - """Return social links.""" - result = {} - profile = obj.profile if hasattr(obj, 'profile') else None - if profile: - links = profile.social_links.all().order_by('platform') - for link in links: - result[link.platform] = link.social_link - return result - - def get_image(self, obj: get_user_model) -> Any: - """Return image.""" - if hasattr(obj, 'profile') and obj.profile: - return AccountLegacyProfileSerializer.get_profile_image( - obj.profile, obj, self.context.get('request') - )['image_url_large'] - - return None - - def get_profile_link(self, obj: get_user_model) -> Any: - """Return profile link.""" - return relative_url_to_absolute_url(f'/u/{obj.username}/', self.context.get('request')) - - -class CourseDetailsBaseSerializer(serializers.ModelSerializer): - """Serializer for course details.""" - status = serializers.SerializerMethodField() - start_date = serializers.SerializerMethodField() - end_date = serializers.SerializerMethodField() - start_enrollment_date = serializers.SerializerMethodField() - end_enrollment_date = serializers.SerializerMethodField() - display_name = serializers.CharField() - image_url = serializers.SerializerMethodField() - org = serializers.CharField() - tenant_ids = serializers.SerializerMethodField() - - class Meta: - model = CourseOverview - fields = [ - 'id', - 'status', - 'self_paced', - 'start_date', - 'end_date', - 'start_enrollment_date', - 'end_enrollment_date', - 'display_name', - 'image_url', - 'org', - 'tenant_ids', - ] - - def get_status(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the course status.""" - now_time = now() - if obj.end and obj.end < now_time: - status = COURSE_STATUSES['archived'] - elif obj.start and obj.start > now_time: - status = COURSE_STATUSES['upcoming'] - else: - status = COURSE_STATUSES['active'] - - return f'{COURSE_STATUS_SELF_PREFIX if obj.self_paced else ""}{status}' - - def get_start_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the start enrollment date.""" - return dt_to_str(obj.enrollment_start) - - def get_end_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the end enrollment date.""" - return dt_to_str(obj.enrollment_end) - - def get_image_url(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the course image URL.""" - return obj.course_image_url - - def get_tenant_ids(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the tenant IDs.""" - return get_tenants_by_org(obj.org) - - def get_start_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the start date.""" - return dt_to_str(obj.start) - - def get_end_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the end date.""" - return dt_to_str(obj.end) - - -class CourseDetailsSerializer(CourseDetailsBaseSerializer): - """Serializer for course details.""" - rating = serializers.SerializerMethodField() - enrolled_count = serializers.IntegerField() - active_count = serializers.IntegerField() - certificates_count = serializers.IntegerField() - completion_rate = serializers.FloatField() - - class Meta: - model = CourseOverview - fields = CourseDetailsBaseSerializer.Meta.fields + [ - 'rating', - 'enrolled_count', - 'active_count', - 'certificates_count', - 'completion_rate', - ] - - def get_rating(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the course rating.""" - return round(obj.rating_total / obj.rating_count if obj.rating_count else 0, 1) - - -class CourseCreateSerializer(serializers.Serializer): - """Serializer for course create.""" - COURSE_NUMBER_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') - MAX_COURSE_ID_LENGTH = 250 - - tenant_id = serializers.IntegerField( - help_text='Tenant ID for the course. Must be a valid tenant ID.', - ) - number = serializers.CharField(help_text='Course code number, like "CS101"') - run = serializers.CharField(help_text='Course run, like "2023_Fall"') - display_name = serializers.CharField( - help_text='Display name of the course.', - ) - start = serializers.DateTimeField( - required=False, - help_text='Start date of the course.', - ) - end = serializers.DateTimeField( - required=False, - help_text='End date of the course.', - ) - enrollment_start = serializers.DateTimeField( - required=False, - help_text='Start date of the course enrollment.', - ) - enrollment_end = serializers.DateTimeField( - required=False, - help_text='End date of the course enrollment.', - ) - self_paced = serializers.BooleanField( - default=False, - help_text='If true, the course is self-paced. If false, the course is instructor-paced.', - ) - invitation_only = serializers.BooleanField( - default=False, - help_text='If true, the course enrollment is invitation-only.', - ) - language = serializers.ChoiceField( - choices=[], - required=False, - help_text='Language code for the course, like "en" for English.', - ) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize the serializer.""" - super().__init__(*args, **kwargs) - self._default_org = '' - self.fields['language'].choices = getattr(settings, 'FX_ALLOWED_COURSE_LANGUAGE_CODES', []) - - @property - def default_org(self) -> str: - """Get the default organization for the tenant.""" - return self._default_org - - def get_absolute_url(self) -> str | None: - """Get the absolute URL for the course.""" - if not self.default_org: - raise serializers.ValidationError('Default organization is not set. Call validate_tenant_id first.') - - course_id = f'course-v1:{self.default_org}+{self.validated_data["number"]}+{self.validated_data["run"]}' - set_request_domain_by_org(self.context.get('request'), self.default_org) - return relative_url_to_absolute_url(f'/courses/{course_id}/', self.context.get('request')) - - def validate_tenant_id(self, tenant_id: Any) -> Any: - """Validate the tenant ID.""" - default_orgs = get_all_tenants_info().get('default_org_per_tenant', {}) - if tenant_id not in default_orgs: - raise serializers.ValidationError( - f'Invalid tenant_id: {tenant_id}. This tenant does not exist or is not configured properly.' - ) - - if not default_orgs[tenant_id]: - raise serializers.ValidationError( - f'No default organization configured for tenant_id: {tenant_id}.' - ) - if tenant_id not in get_org_to_tenant_map().get(default_orgs[tenant_id], []): - raise serializers.ValidationError( - f'Invalid default organization ({default_orgs[tenant_id]}) configured for tenant ID {tenant_id}.' - ) - self._default_org = default_orgs[tenant_id] - return tenant_id - - def validate_number(self, value: str) -> str: - """Validate that number matches COURSE_NUMBER_PATTERN.""" - if not self.COURSE_NUMBER_PATTERN.match(value): - raise serializers.ValidationError( - f'Invalid number ({value}). Only alphanumeric characters, underscores, and hyphens are allowed.' - ) - return value - - def validate_run(self, value: str) -> str: - """Validate that run matches COURSE_NUMBER_PATTERN.""" - if not self.COURSE_NUMBER_PATTERN.match(value): - raise serializers.ValidationError( - f'Invalid run ({value}). Only alphanumeric characters, underscores, and hyphens are allowed.' - ) - return value - - def validate(self, attrs: dict) -> dict: - """Validate the course creation data.""" - number = attrs.get('number', '') - run = attrs.get('run', '') - if len(f'course-v1:{self.default_org}+{number}+{run}') > self.MAX_COURSE_ID_LENGTH: - raise serializers.ValidationError( - f'Course ID is too long. The maximum length is {self.MAX_COURSE_ID_LENGTH} characters.' - ) - - dates = { - 'start': attrs.get('start', CourseFields.start.default), - 'end': attrs.get('end'), - 'enrollment_start': attrs.get('enrollment_start', CourseFields.enrollment_start.default), - 'enrollment_end': attrs.get('enrollment_end'), - } - greater_or_equal_rules = [ - ('end', 'start'), - ('enrollment_end', 'enrollment_start'), - ('start', 'enrollment_start'), - ('end', 'enrollment_end'), - ] - for rule in greater_or_equal_rules: - lvalue, rvalue = rule - if dates[lvalue] and dates[rvalue] and dates[lvalue] < dates[rvalue]: - raise serializers.ValidationError( - f'{lvalue} cannot be before {rvalue}. {lvalue}[{dates[lvalue]}], {rvalue}[{dates[rvalue]}]' - ) - - return attrs - - @staticmethod - def update_course_discussions_settings(course: Any) -> None: - """ - Add course discussion settings to the course. - CMS References: cms.djangoapps.contentstore.utils.update_course_discussions_settings - """ - provider = DiscussionsConfiguration.get(context_key=course.id).provider_type - course.discussions_settings['provider_type'] = provider - modulestore().update_item(course, course.published_by) - - @staticmethod - def initialize_permissions(course: Any, user: get_user_model) -> None: - """ - seeds permissions, enrolls the user, and assigns the default role for the course. - - CMS Reference: cms.djangoapps.contentstore.utils.initialize_permissions - """ - seed_permissions_roles(course.id) - CourseEnrollment.enroll(user, course.id) - assign_default_role(course.id, user) - - @staticmethod - def add_roles_and_permissions(course: Any, user: get_user_model) -> None: - """ - Assigns instructor and staff roles and required permissions - """ - CourseInstructorRole(course.id).add_users(user) - add_users(user, CourseStaffRole(course.id), user) - CourseCreateSerializer.initialize_permissions(course, user) - - def create(self, validated_data: dict) -> Any: - """ - Create new course. - - TODO: Update code to create rerun. - """ - user = self.context['request'].user - tenant_id = validated_data['tenant_id'] - org = get_all_tenants_info()['default_org_per_tenant'][tenant_id] - number = validated_data.get('number') - run = validated_data['run'] - - field_names = [ - 'start', - 'end', - 'enrollment_start', - 'enrollment_end', - 'language', - 'self_paced', - 'invitation_only', - 'display_name', - ] - fields = { - field_name: validated_data.get(field_name) for field_name in field_names if field_name in validated_data - } - - try: - org_data = ensure_organization(org) - except Exception as exc: - raise serializers.ValidationError( - 'Organization does not exist. Please add the organization before proceeding.' - ) from exc - - try: - store = modulestore().default_modulestore.get_modulestore_type() - with modulestore().default_store(store): - new_course = modulestore().create_course( - org, - number, - run, - user.id, - fields=fields, - ) - self.add_roles_and_permissions(new_course, user) - add_organization_course(org_data, new_course.id) - self.update_course_discussions_settings(new_course) - except DuplicateCourseError as exc: - raise serializers.ValidationError( - f'Course with org: {org}, number: {number}, run: {run} already exists.' - ) from exc - - return new_course - - def update(self, instance: Any, validated_data: Any) -> Any: - """Not implemented: Update an existing object.""" - raise ValueError('This serializer does not support update.') - - -class LearnerCoursesDetailsSerializer(CourseDetailsBaseSerializer): - """Serializer for learner's courses details.""" - enrollment_date = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) - last_activity = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) - certificate_url = serializers.SerializerMethodField() - progress_url = serializers.SerializerMethodField() - grades_url = serializers.SerializerMethodField() - progress = serializers.SerializerMethodField() - grade = serializers.SerializerMethodField() - - class Meta: - model = CourseOverview - fields = CourseDetailsBaseSerializer.Meta.fields + [ - 'enrollment_date', - 'last_activity', - 'certificate_url', - 'progress_url', - 'grades_url', - 'progress', - 'grade', - ] - - def get_certificate_url(self, obj: CourseOverview) -> Any: - """Return the certificate URL.""" - user = get_user_model().objects.get(id=obj.related_user_id) - return get_certificate_url(self.context.get('request'), user, obj.id) - - def get_progress_url(self, obj: CourseOverview) -> Any: - """Return the certificate URL.""" - set_request_domain_by_org(self.context.get('request'), obj.org) - return relative_url_to_absolute_url( - f'/learning/course/{obj.id}/progress/{obj.related_user_id}/', - self.context.get('request') - ) - - def get_grades_url(self, obj: CourseOverview) -> Any: - """Return the certificate URL.""" - set_request_domain_by_org(self.context.get('request'), obj.org) - return relative_url_to_absolute_url( - f'/gradebook/{obj.id}/', - self.context.get('request') - ) - - def get_progress(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the certificate URL.""" - user = get_user_model().objects.get(id=obj.related_user_id) - return get_course_blocks_completion_summary(obj.id, user) - - def get_grade(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use - """Return the grade summary.""" - collected_block_structure = get_block_structure_manager(obj.id).get_collected() - course_grade = CourseGradeFactory().read( - get_user_model().objects.get(id=obj.related_user_id), - collected_block_structure=collected_block_structure - ) - - return { - 'percent': course_grade.percent, - 'letter_grade': course_grade.letter_grade, - 'is_passing': course_grade.passed, - } - - -class UserRolesSerializer(LearnerBasicDetailsSerializer): - """Serializer for user roles.""" - tenants = serializers.SerializerMethodField() - global_roles = serializers.SerializerMethodField() - - def __init__(self, instance: Any | None = None, data: Any = empty, **kwargs: Any): - """Initialize the serializer.""" - self._org_tenant: dict[str, list[int]] = {} - self._roles_data: dict[Any, Any] = {} - - permission_info = kwargs['context']['request'].fx_permission_info - self.orgs_filter = permission_info['view_allowed_any_access_orgs'] - self.permitted_tenant_ids = permission_info['view_allowed_tenant_ids_any_access'] - self.query_params = self.parse_query_params(kwargs['context']['request'].query_params) - - if instance: - self.construct_roles_data(instance if isinstance(instance, list) else [instance]) - - super().__init__(instance, data, **kwargs) - - @staticmethod - def parse_query_params(query_params: dict[str, Any]) -> dict[str, Any]: - """ - Parse the query parameters. - - :param query_params: The query parameters. - :type query_params: dict[str, Any] - """ - result = { - 'search_text': query_params.get('search_text', ''), - 'course_ids_filter': query_params[ - 'only_course_ids' - ].split(',') if query_params.get('only_course_ids') else [], - 'roles_filter': query_params.get('only_roles', '').split(',') if query_params.get('only_roles') else [], - 'include_hidden_roles': query_params.get('include_hidden_roles', '0') == '1', - } - - if query_params.get('active_users_filter') is not None: - result['active_filter'] = query_params['active_users_filter'] == '1' - else: - result['active_filter'] = None - - excluded_role_types = query_params.get('excluded_role_types', '').split(',') \ - if query_params.get('excluded_role_types') else [] - - result['excluded_role_types'] = [] - if 'global' in excluded_role_types: - result['excluded_role_types'].append(RoleType.GLOBAL) - - if 'tenant' in excluded_role_types: - result['excluded_role_types'].append(RoleType.ORG_WIDE) - - if 'course' in excluded_role_types: - result['excluded_role_types'].append(RoleType.COURSE_SPECIFIC) - - return result - - def get_org_tenants(self, org: str) -> list[int]: - """ - Get the tenants for an organization. - - :param org: The organization to get the tenants for. - :type org: str - :return: The tenants. - :rtype: list[int] - """ - result = self._org_tenant.get(org) - if not result: - result = get_tenants_by_org(org) - self._org_tenant[org] = result - - return result or [] - - def construct_roles_data(self, users: list[get_user_model]) -> None: - """ - Construct the roles data. - - { - "": { - "": { - "tenant_roles": ["", ""], - "course_roles": { - "": ["", ""], - "": ["", ""], - }, - }, - .... - }, - .... - } - - :param users: The user instances. - :type users: list[get_user_model] - """ - self._roles_data = {} - for user in users: - self._roles_data[user.id] = {} - - records = get_course_access_roles_queryset( - self.orgs_filter, - remove_redundant=True, - users=users, - search_text=self.query_params['search_text'], - roles_filter=self.query_params['roles_filter'], - active_filter=self.query_params['active_filter'], - course_ids_filter=self.query_params['course_ids_filter'], - excluded_role_types=self.query_params['excluded_role_types'], - excluded_hidden_roles=not self.query_params['include_hidden_roles'], - ) - - for record in records or []: - usr_data = self._roles_data[record.user_id] - for tenant_id in self.get_org_tenants(record.org): - if tenant_id not in self.permitted_tenant_ids: - continue - - if tenant_id not in usr_data: - usr_data[tenant_id] = { - 'tenant_roles': [], - 'course_roles': {}, - } - - course_id = str(record.course_id) if record.course_id else None - if course_id and course_id not in usr_data[tenant_id]['course_roles']: - usr_data[tenant_id]['course_roles'][course_id] = [] - - if course_id: - usr_data[tenant_id]['course_roles'][course_id].append(record.role) - elif record.role not in usr_data[tenant_id]['tenant_roles']: - usr_data[tenant_id]['tenant_roles'].append(record.role) - - @property - def roles_data(self) -> dict[Any, Any] | None: - """Get the roles data.""" - return self._roles_data - - def get_tenants(self, obj: get_user_model) -> Any: - """Return the tenants.""" - return self.roles_data.get(obj.id, {}) if self.roles_data else {} - - def get_global_roles(self, obj: get_user_model) -> Any: # pylint:disable=no-self-use - """Return the global roles.""" - roles_dict = get_user_course_access_roles(obj)['roles'] - return [role for role in roles_dict if role in COURSE_ACCESS_ROLES_GLOBAL] - - class Meta: - model = get_user_model() - fields = [ - 'user_id', - 'email', - 'username', - 'national_id', - 'full_name', - 'alternative_full_name', - 'global_roles', - 'tenants', - ] - - -class ReadOnlySerializer(serializers.Serializer): - """A serializer that is only used for read operations and does not require create/update methods.""" - - def create(self, validated_data: Any) -> Any: - """Not implemented: Create a new object.""" - raise ValueError('This serializer is read-only and does not support object creation.') - - def update(self, instance: Any, validated_data: Any) -> Any: - """Not implemented: Update an existing object.""" - raise ValueError('This serializer is read-only and does not support object updates.') - - -class LibrarySerializer(serializers.Serializer): - """Serializer for library.""" - library_key = serializers.CharField(source='location.library_key', read_only=True) - edited_by = serializers.IntegerField(source='_edited_by', read_only=True) - edited_on = serializers.DateTimeField(source='_edited_on', read_only=True) - tenant_ids = serializers.SerializerMethodField(read_only=True) - display_name = serializers.CharField() - tenant_id = serializers.IntegerField(write_only=True) - number = serializers.CharField(write_only=True) - - def get_tenant_ids(self, obj: Any) -> Any: # pylint: disable=no-self-use - """Return the tenant IDs.""" - return get_tenants_by_org(obj.location.library_key.org) - - def validate_tenant_id(self, tenant_id: Any) -> Any: # pylint: disable=no-self-use - """Validate the tenant ID.""" - default_orgs = get_all_tenants_info().get('default_org_per_tenant', {}) - if tenant_id not in default_orgs: - raise serializers.ValidationError( - f'Invalid tenant_id: "{tenant_id}". This tenant does not exist or is not configured properly.' - ) - if not default_orgs[tenant_id]: - raise serializers.ValidationError( - f'No default organization configured for tenant_id: "{tenant_id}".' - ) - if tenant_id not in get_org_to_tenant_map().get(default_orgs[tenant_id], []): - raise serializers.ValidationError( - f'Invalid default organization "{default_orgs[tenant_id]}" configured for tenant ID "{tenant_id}". ' - 'This organization is not associated with the tenant.' - ) - return tenant_id - - def create(self, validated_data: Any) -> Any: - """Create new library object.""" - user = self.context['request'].user - tenant_id = validated_data['tenant_id'] - org = get_all_tenants_info()['default_org_per_tenant'][tenant_id] - try: - store = modulestore() - with store.default_store(ModuleStoreEnum.Type.split): - library = store.create_library( - org=org, - library=validated_data['number'], - user_id=user.id, - fields={ - 'display_name': validated_data['display_name'] - }, - ) - # can't use auth.add_users here b/c it requires user to already have Instructor perms in this course - CourseInstructorRole(library.location.library_key).add_users(user) - add_users(user, CourseStaffRole(library.location.library_key), user) - return library - except DuplicateCourseError as exc: - raise serializers.ValidationError( - f'Library with org: {org} and number: {validated_data["number"]} already exists.' - ) from exc - - def update(self, instance: Any, validated_data: Any) -> Any: - """Not implemented: Update an existing object.""" - raise ValueError('This serializer does not support update.') - - def to_representation(self, instance: Any) -> dict: - """Return representation.""" - instance.org = instance.location.library_key.org - rep = super().to_representation(instance) - return rep - - -class CoursesFeedbackSerializer(serializers.ModelSerializer): - """Serializer for courses feedback.""" - course_id = serializers.SerializerMethodField() - course_name = serializers.SerializerMethodField() - author_username = serializers.SerializerMethodField() - author_full_name = serializers.SerializerMethodField() - author_altternative_full_name = serializers.SerializerMethodField() - author_email = serializers.SerializerMethodField() - - class Meta: - model = FeedbackCourse - fields = ( - 'id', 'course_id', 'course_name', 'author_username', 'author_full_name', 'author_altternative_full_name', - 'author_email', 'rating_content', 'feedback', 'public', 'rating_instructors', 'recommended' - ) - - def get_course_id(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Get course id""" - return str(obj.course_id.id) - - def get_course_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Get course id""" - return obj.course_id.display_name - - def get_author_username(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Get course id""" - return str(obj.author.username) - - def get_author_email(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Get course id""" - return str(obj.author.email) - - def get_author_full_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Return full name.""" - return extract_full_name_from_user(obj.author) - - def get_author_altternative_full_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use - """Return alternative full name.""" - return ( - extract_arabic_name_from_user(obj.author) or - extract_full_name_from_user(obj.author, alternative=True) - ) - - -class AggregatedCountsQuerySettingsSerializer(ReadOnlySerializer): - """Serializer for aggregated counts settings.""" - aggregate_period = serializers.CharField() - date_from = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) - date_to = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) - - -class AggregatedCountsTotalsSerializer(ReadOnlySerializer): - enrollments_count = serializers.IntegerField(required=False, allow_null=True) - - -class AggregatedCountsValuesSerializer(ReadOnlySerializer): - label = serializers.CharField() - value = serializers.IntegerField() - - -class AggregatedCountsAllTenantsSerializer(ReadOnlySerializer): - enrollments_count = AggregatedCountsValuesSerializer(required=False, allow_null=True, many=True) - totals = AggregatedCountsTotalsSerializer() - - -class AggregatedCountsOneTenantSerializer(AggregatedCountsAllTenantsSerializer): - tenant_id = serializers.IntegerField() - - -class AggregatedCountsSerializer(ReadOnlySerializer): - query_settings = AggregatedCountsQuerySettingsSerializer() - all_tenants = AggregatedCountsAllTenantsSerializer() - by_tenant = AggregatedCountsOneTenantSerializer(many=True) - limited_access = serializers.BooleanField() - - -class FxPermissionInfoSerializerMixin: # pylint: disable=too-few-public-methods - """Mixin to add a property fx_permission_info that loads the fx_permission_info from the request context.""" - - @property - def fx_permission_info(self) -> dict[str, Any]: - """ - Get the fx_permission_info from the request context. - - :return: The fx_permission_info dictionary. - :rtype: dict[str, Any] - """ - request = self.context.get('request') # type: ignore[attr-defined] - if not request: - raise serializers.ValidationError('Unable to load fx_permission_info as request object is missing.') - if not hasattr(request, 'fx_permission_info'): - raise serializers.ValidationError('fx_permission_info is missing in the request context of the serializer!') - - return request.fx_permission_info - - -class FileUploadSerializer(FxPermissionInfoSerializerMixin, ReadOnlySerializer): - """ - Serializer for handling the file upload request. It validates and serializes the input data. - """ - file = serializers.FileField(help_text='File to be uploaded') - slug = serializers.SlugField(help_text='File slug. Only alphanumeric characters, and underscores are allowed.') - tenant_id = serializers.IntegerField(help_text='Tenant ID') - - def validate_tenant_id(self, value: int) -> int: - """ - Custom validation for tenant_id to ensure that the tenant exists. - """ - try: - TenantConfig.objects.get(id=value) - except TenantConfig.DoesNotExist as exc: - raise serializers.ValidationError(f'Tenant with ID {value} does not exist.') from exc - - if value not in self.fx_permission_info['view_allowed_tenant_ids_full_access']: - raise serializers.ValidationError(f'User does not have have required access for tenant ({value}).') - - return value - - -class TenantAssetSerializer(FxPermissionInfoSerializerMixin, serializers.ModelSerializer): - """Serializer for Data Export Task""" - file_url = serializers.SerializerMethodField() - file = serializers.FileField(write_only=True) - tenant_id = serializers.PrimaryKeyRelatedField(queryset=TenantConfig.objects.all(), source='tenant') - - class Meta: - model = TenantAsset - fields = ['id', 'tenant_id', 'slug', 'file', 'file_url', 'updated_by', 'updated_at'] - read_only_fields = ['id', 'updated_at', 'file_url', 'updated_by'] - - def __init__(self, *args: Any, **kwargs: Any): - """Override init to dynamically change fields. This change is only for swagger docs""" - include_write_only = kwargs.pop('include_write_only', True) - super().__init__(*args, **kwargs) - if include_write_only is False: - self.fields.pop('file') - - def get_unique_together_validators(self) -> list: - """ - Overriding this method to bypass the unique_together constraint on 'tenant' and 'slug'. - This prevents an error from being raised before reaching the create or update logic. - """ - return [] - - def validate_file(self, file: Any) -> Any: # pylint: disable=no-self-use - """ - Custom validation for file to ensure file extension. - """ - file_extension = os.path.splitext(file.name)[1] - if file_extension.lower() not in ALLOWED_FILE_EXTENSIONS: - raise serializers.ValidationError(f'Invalid file type. Allowed types are {ALLOWED_FILE_EXTENSIONS}.') - return file - - def validate_tenant_id(self, tenant: TenantConfig) -> int: - """ - Custom validation for tenant to ensure that the tenant permissions. - """ - if tenant.id not in self.fx_permission_info['view_allowed_tenant_ids_full_access']: - template_tenant_id = get_all_tenants_info()['template_tenant']['tenant_id'] - if self.fx_permission_info['is_system_staff_user'] and template_tenant_id == tenant.id: - return tenant - raise serializers.ValidationError( - f'User does not have have required access for tenant ({tenant.id}).' - ) - - return tenant - - def validate_slug(self, slug: str) -> str: - """ - Custom validation for the slug to ensure it doesn't start with an underscore unless the user is a system staff. - """ - if slug.startswith('_') and not self.fx_permission_info['is_system_staff_user']: - raise serializers.ValidationError( - 'Slug cannot start with an underscore unless the user is a system staff.' - ) - return slug - - def get_file_url(self, obj: TenantAsset) -> Any: # pylint: disable=no-self-use - """Return file url.""" - return obj.file.url - - def create(self, validated_data: dict) -> TenantAsset: - """ - Override the create method to handle scenarios where a user tries to upload a new asset with the same slug - for the same tenant. Instead of creating a new asset, the existing asset will be updated with the new file. - """ - request = self.context.get('request') - asset, _ = TenantAsset.objects.update_or_create( - tenant=validated_data['tenant'], slug=validated_data['slug'], - defaults={ - 'file': validated_data['file'], - 'updated_by': request.user, - 'updated_at': now() - } - ) - return asset - - -class TenantConfigSerializer(ReadOnlySerializer): - """Serializer for Tenant Configurations.""" - values = serializers.DictField(default=dict) - not_permitted = serializers.ListField(child=serializers.CharField(), default=list) - bad_keys = serializers.ListField(child=serializers.CharField(), default=list) - revision_ids = serializers.SerializerMethodField() - - def get_revision_ids(self, obj: Any) -> Dict[str, str]: # pylint: disable=no-self-use - """Return the revision IDs as strings.""" - revision_ids = obj.get('revision_ids', {}) - return {key: str(value) for key, value in revision_ids.items()} diff --git a/futurex_openedx_extensions/dashboard/serializers/__init__.py b/futurex_openedx_extensions/dashboard/serializers/__init__.py new file mode 100644 index 00000000..702144b9 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/__init__.py @@ -0,0 +1,85 @@ +"""Serializers for the dashboard API.""" +# Import all serializers to make them available from the serializers package + +# Common serializers +from futurex_openedx_extensions.dashboard.serializers.common import ( + DataExportTaskSerializer, + FxPermissionInfoSerializerMixin, + ReadOnlySerializer, +) + +# Learner serializers +from futurex_openedx_extensions.dashboard.serializers.learners import ( + CourseScoreAndCertificateSerializer, + LearnerBasicDetailsSerializer, + LearnerDetailsExtendedSerializer, + LearnerDetailsForCourseSerializer, + LearnerDetailsSerializer, + LearnerEnrollmentSerializer, +) + +# Course serializers +from futurex_openedx_extensions.dashboard.serializers.courses import ( + CourseCreateSerializer, + CourseDetailsBaseSerializer, + CourseDetailsSerializer, + CoursesFeedbackSerializer, + LearnerCoursesDetailsSerializer, + LibrarySerializer, +) + +# Role serializers +from futurex_openedx_extensions.dashboard.serializers.roles import ( + UserRolesSerializer, +) + +# Config serializers +from futurex_openedx_extensions.dashboard.serializers.config import ( + FileUploadSerializer, + TenantAssetSerializer, + TenantConfigSerializer, +) + +# Statistics serializers +from futurex_openedx_extensions.dashboard.serializers.statistics import ( + AggregatedCountsAllTenantsSerializer, + AggregatedCountsOneTenantSerializer, + AggregatedCountsQuerySettingsSerializer, + AggregatedCountsSerializer, + AggregatedCountsTotalsSerializer, + AggregatedCountsValuesSerializer, +) + +__all__ = [ + # Common + 'DataExportTaskSerializer', + 'FxPermissionInfoSerializerMixin', + 'ReadOnlySerializer', + # Learners + 'CourseScoreAndCertificateSerializer', + 'LearnerBasicDetailsSerializer', + 'LearnerDetailsExtendedSerializer', + 'LearnerDetailsForCourseSerializer', + 'LearnerDetailsSerializer', + 'LearnerEnrollmentSerializer', + # Courses + 'CourseCreateSerializer', + 'CourseDetailsBaseSerializer', + 'CourseDetailsSerializer', + 'CoursesFeedbackSerializer', + 'LearnerCoursesDetailsSerializer', + 'LibrarySerializer', + # Roles + 'UserRolesSerializer', + # Config + 'FileUploadSerializer', + 'TenantAssetSerializer', + 'TenantConfigSerializer', + # Statistics + 'AggregatedCountsAllTenantsSerializer', + 'AggregatedCountsOneTenantSerializer', + 'AggregatedCountsQuerySettingsSerializer', + 'AggregatedCountsSerializer', + 'AggregatedCountsTotalsSerializer', + 'AggregatedCountsValuesSerializer', +] diff --git a/futurex_openedx_extensions/dashboard/serializers/common.py b/futurex_openedx_extensions/dashboard/serializers/common.py new file mode 100644 index 00000000..24b4e248 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/common.py @@ -0,0 +1,74 @@ +"""Common serializers and mixins for the dashboard API.""" +# pylint: disable=too-many-lines +from __future__ import annotations + +import re +from typing import Any + +from rest_framework import serializers + +from futurex_openedx_extensions.dashboard.custom_serializers import ( + ModelSerializerOptionalFields, + SerializerOptionalMethodField, +) +from futurex_openedx_extensions.helpers.export_csv import get_exported_file_url +from futurex_openedx_extensions.helpers.models import DataExportTask + + +class DataExportTaskSerializer(ModelSerializerOptionalFields): + """Serializer for Data Export Task""" + download_url = SerializerOptionalMethodField(field_tags=['download_url']) + + class Meta: + model = DataExportTask + fields = [ + 'id', + 'user_id', + 'tenant_id', + 'status', + 'progress', + 'view_name', + 'related_id', + 'filename', + 'notes', + 'created_at', + 'started_at', + 'completed_at', + 'download_url', + 'error_message', + ] + read_only_fields = [ + field.name for field in DataExportTask._meta.fields if field.name not in ['notes'] + ] + + def validate_notes(self: Any, value: str) -> str: # pylint: disable=no-self-use + """Sanitize the notes field and escape HTML tags.""" + value = re.sub(r'<', '<', value) + value = re.sub(r'>', '>', value) + return value + + def get_download_url(self, obj: DataExportTask) -> Any: # pylint: disable=no-self-use + """Return download url.""" + return get_exported_file_url(obj) + + +class ReadOnlySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for read-only endpoints.""" + + +class FxPermissionInfoSerializerMixin: + """ + Mixin to add permission info to serializers. + + This mixin adds two optional fields to the serializer to show the editable status of the object. + """ + can_edit = SerializerOptionalMethodField(field_tags=['can_edit'], help_text='Can the user edit this object?') + can_delete = SerializerOptionalMethodField(field_tags=['can_delete'], help_text='Can the user delete this object?') + + def get_can_edit(self, obj: Any) -> Any: # pylint: disable=no-self-use, unused-argument + """Check if the user can edit the object.""" + return True + + def get_can_delete(self, obj: Any) -> Any: # pylint: disable=no-self-use, unused-argument + """Check if the user can delete the object.""" + return True diff --git a/futurex_openedx_extensions/dashboard/serializers/config.py b/futurex_openedx_extensions/dashboard/serializers/config.py new file mode 100644 index 00000000..aef26612 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/config.py @@ -0,0 +1,164 @@ +"""Configuration-related serializers for the dashboard API.""" +from __future__ import annotations + +import os +from typing import Any, Dict + +from django.utils.timezone import now +from eox_tenant.models import TenantConfig +from rest_framework import serializers + +from futurex_openedx_extensions.dashboard.custom_serializers import SerializerOptionalMethodField +from futurex_openedx_extensions.helpers.constants import ALLOWED_FILE_EXTENSIONS +from futurex_openedx_extensions.helpers.models import TenantAsset +from futurex_openedx_extensions.helpers.tenants import get_all_tenants_info + + +class FxPermissionInfoSerializerMixin: + """ + Mixin to add permission info to serializers. + + This mixin adds two optional fields to the serializer to show the editable status of the object. + """ + can_edit = SerializerOptionalMethodField(field_tags=['can_edit'], help_text='Can the user edit this object?') + can_delete = SerializerOptionalMethodField(field_tags=['can_delete'], help_text='Can the user delete this object?') + + @property + def fx_permission_info(self) -> dict[str, Any]: + """Get the fx_permission_info from the context.""" + return self.context.get('request').fx_permission_info # type: ignore + + def get_can_edit(self, obj: Any) -> Any: # pylint: disable=no-self-use, unused-argument + """Check if the user can edit the object.""" + return True + + def get_can_delete(self, obj: Any) -> Any: # pylint: disable=no-self-use, unused-argument + """Check if the user can delete the object.""" + return True + + +class ReadOnlySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for read-only endpoints.""" + + def create(self, validated_data: Any) -> Any: + """Not implemented: Create a new object.""" + raise ValueError('This serializer is read-only and does not support object creation.') + + def update(self, instance: Any, validated_data: Any) -> Any: + """Not implemented: Update an existing object.""" + raise ValueError('This serializer is read-only and does not support object updates.') + + +class FileUploadSerializer(FxPermissionInfoSerializerMixin, ReadOnlySerializer): + """ + Serializer for handling the file upload request. It validates and serializes the input data. + """ + file = serializers.FileField(help_text='File to be uploaded') + slug = serializers.SlugField(help_text='File slug. Only alphanumeric characters, and underscores are allowed.') + tenant_id = serializers.IntegerField(help_text='Tenant ID') + + def validate_tenant_id(self, value: int) -> int: + """ + Custom validation for tenant_id to ensure that the tenant exists. + """ + try: + TenantConfig.objects.get(id=value) + except TenantConfig.DoesNotExist as exc: + raise serializers.ValidationError(f'Tenant with ID {value} does not exist.') from exc + + if value not in self.fx_permission_info['view_allowed_tenant_ids_full_access']: + raise serializers.ValidationError(f'User does not have have required access for tenant ({value}).') + + return value + + +class TenantAssetSerializer(FxPermissionInfoSerializerMixin, serializers.ModelSerializer): + """Serializer for Data Export Task""" + file_url = serializers.SerializerMethodField() + file = serializers.FileField(write_only=True) + tenant_id = serializers.PrimaryKeyRelatedField(queryset=TenantConfig.objects.all(), source='tenant') + + class Meta: + model = TenantAsset + fields = ['id', 'tenant_id', 'slug', 'file', 'file_url', 'updated_by', 'updated_at'] + read_only_fields = ['id', 'updated_at', 'file_url', 'updated_by'] + + def __init__(self, *args: Any, **kwargs: Any): + """Override init to dynamically change fields. This change is only for swagger docs""" + include_write_only = kwargs.pop('include_write_only', True) + super().__init__(*args, **kwargs) + if include_write_only is False: + self.fields.pop('file') + + def get_unique_together_validators(self) -> list: + """ + Overriding this method to bypass the unique_together constraint on 'tenant' and 'slug'. + This prevents an error from being raised before reaching the create or update logic. + """ + return [] + + def validate_file(self, file: Any) -> Any: # pylint: disable=no-self-use + """ + Custom validation for file to ensure file extension. + """ + file_extension = os.path.splitext(file.name)[1] + if file_extension.lower() not in ALLOWED_FILE_EXTENSIONS: + raise serializers.ValidationError(f'Invalid file type. Allowed types are {ALLOWED_FILE_EXTENSIONS}.') + return file + + def validate_tenant_id(self, tenant: TenantConfig) -> int: + """ + Custom validation for tenant to ensure that the tenant permissions. + """ + if tenant.id not in self.fx_permission_info['view_allowed_tenant_ids_full_access']: + template_tenant_id = get_all_tenants_info()['template_tenant']['tenant_id'] + if self.fx_permission_info['is_system_staff_user'] and template_tenant_id == tenant.id: + return tenant + raise serializers.ValidationError( + f'User does not have have required access for tenant ({tenant.id}).' + ) + + return tenant + + def validate_slug(self, slug: str) -> str: + """ + Custom validation for the slug to ensure it doesn't start with an underscore unless the user is a system staff. + """ + if slug.startswith('_') and not self.fx_permission_info['is_system_staff_user']: + raise serializers.ValidationError( + 'Slug cannot start with an underscore unless the user is a system staff.' + ) + return slug + + def get_file_url(self, obj: TenantAsset) -> Any: # pylint: disable=no-self-use + """Return file url.""" + return obj.file.url + + def create(self, validated_data: dict) -> TenantAsset: + """ + Override the create method to handle scenarios where a user tries to upload a new asset with the same slug + for the same tenant. Instead of creating a new asset, the existing asset will be updated with the new file. + """ + request = self.context.get('request') + asset, _ = TenantAsset.objects.update_or_create( + tenant=validated_data['tenant'], slug=validated_data['slug'], + defaults={ + 'file': validated_data['file'], + 'updated_by': request.user, + 'updated_at': now() + } + ) + return asset + + +class TenantConfigSerializer(ReadOnlySerializer): + """Serializer for Tenant Configurations.""" + values = serializers.DictField(default=dict) + not_permitted = serializers.ListField(child=serializers.CharField(), default=list) + bad_keys = serializers.ListField(child=serializers.CharField(), default=list) + revision_ids = serializers.SerializerMethodField() + + def get_revision_ids(self, obj: Any) -> Dict[str, str]: # pylint: disable=no-self-use + """Return the revision IDs as strings.""" + revision_ids = obj.get('revision_ids', {}) + return {key: str(value) for key, value in revision_ids.items()} diff --git a/futurex_openedx_extensions/dashboard/serializers/courses.py b/futurex_openedx_extensions/dashboard/serializers/courses.py new file mode 100644 index 00000000..fdfd4e58 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/courses.py @@ -0,0 +1,523 @@ +"""Course-related serializers for the dashboard API.""" +from __future__ import annotations + +import re +from typing import Any + +from common.djangoapps.student.auth import add_users +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from eox_nelp.course_experience.models import FeedbackCourse +from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from lms.djangoapps.grades.api import CourseGradeFactory +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.djangoapps.django_comment_common.models import assign_default_role +from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from organizations.api import add_organization_course, ensure_organization +from rest_framework import serializers +from xmodule.course_block import CourseFields +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import DuplicateCourseError + +from futurex_openedx_extensions.helpers.constants import ( + COURSE_STATUS_SELF_PREFIX, + COURSE_STATUSES, +) +from futurex_openedx_extensions.helpers.converters import ( + DEFAULT_DATETIME_FORMAT, + dt_to_str, + relative_url_to_absolute_url, +) +from futurex_openedx_extensions.helpers.extractors import extract_arabic_name_from_user, extract_full_name_from_user +from futurex_openedx_extensions.helpers.tenants import ( + get_all_tenants_info, + get_org_to_tenant_map, + get_tenants_by_org, + set_request_domain_by_org, +) +from futurex_openedx_extensions.helpers.certificates import get_certificate_url + + +class CourseDetailsBaseSerializer(serializers.ModelSerializer): + """Serializer for course details.""" + status = serializers.SerializerMethodField() + start_date = serializers.SerializerMethodField() + end_date = serializers.SerializerMethodField() + start_enrollment_date = serializers.SerializerMethodField() + end_enrollment_date = serializers.SerializerMethodField() + display_name = serializers.CharField() + image_url = serializers.SerializerMethodField() + org = serializers.CharField() + tenant_ids = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = [ + 'id', + 'status', + 'self_paced', + 'start_date', + 'end_date', + 'start_enrollment_date', + 'end_enrollment_date', + 'display_name', + 'image_url', + 'org', + 'tenant_ids', + ] + + def get_status(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the course status.""" + now_time = now() + if obj.end and obj.end < now_time: + status = COURSE_STATUSES['archived'] + elif obj.start and obj.start > now_time: + status = COURSE_STATUSES['upcoming'] + else: + status = COURSE_STATUSES['active'] + + return f'{COURSE_STATUS_SELF_PREFIX if obj.self_paced else ""}{status}' + + def get_start_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the start enrollment date.""" + return dt_to_str(obj.enrollment_start) + + def get_end_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the end enrollment date.""" + return dt_to_str(obj.enrollment_end) + + def get_image_url(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the course image URL.""" + return obj.course_image_url + + def get_tenant_ids(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the tenant IDs.""" + return get_tenants_by_org(obj.org) + + def get_start_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the start date.""" + return dt_to_str(obj.start) + + def get_end_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the end date.""" + return dt_to_str(obj.end) + + +class CourseDetailsSerializer(CourseDetailsBaseSerializer): + """Serializer for course details.""" + rating = serializers.SerializerMethodField() + enrolled_count = serializers.IntegerField() + active_count = serializers.IntegerField() + certificates_count = serializers.IntegerField() + completion_rate = serializers.FloatField() + + class Meta: + model = CourseOverview + fields = CourseDetailsBaseSerializer.Meta.fields + [ + 'rating', + 'enrolled_count', + 'active_count', + 'certificates_count', + 'completion_rate', + ] + + def get_rating(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the course rating.""" + return round(obj.rating_total / obj.rating_count if obj.rating_count else 0, 1) + + +class CourseCreateSerializer(serializers.Serializer): + """Serializer for course create.""" + COURSE_NUMBER_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') + MAX_COURSE_ID_LENGTH = 250 + + tenant_id = serializers.IntegerField( + help_text='Tenant ID for the course. Must be a valid tenant ID.', + ) + number = serializers.CharField(help_text='Course code number, like "CS101"') + run = serializers.CharField(help_text='Course run, like "2023_Fall"') + display_name = serializers.CharField( + help_text='Display name of the course.', + ) + start = serializers.DateTimeField( + required=False, + help_text='Start date of the course.', + ) + end = serializers.DateTimeField( + required=False, + help_text='End date of the course.', + ) + enrollment_start = serializers.DateTimeField( + required=False, + help_text='Start date of the course enrollment.', + ) + enrollment_end = serializers.DateTimeField( + required=False, + help_text='End date of the course enrollment.', + ) + self_paced = serializers.BooleanField( + default=False, + help_text='If true, the course is self-paced. If false, the course is instructor-paced.', + ) + invitation_only = serializers.BooleanField( + default=False, + help_text='If true, the course enrollment is invitation-only.', + ) + language = serializers.ChoiceField( + choices=[], + required=False, + help_text='Language code for the course, like "en" for English.', + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the serializer.""" + super().__init__(*args, **kwargs) + self._default_org = '' + self.fields['language'].choices = getattr(settings, 'FX_ALLOWED_COURSE_LANGUAGE_CODES', []) + + @property + def default_org(self) -> str: + """Get the default organization for the tenant.""" + return self._default_org + + def get_absolute_url(self) -> str | None: + """Get the absolute URL for the course.""" + if not self.default_org: + raise serializers.ValidationError('Default organization is not set. Call validate_tenant_id first.') + + course_id = f'course-v1:{self.default_org}+{self.validated_data["number"]}+{self.validated_data["run"]}' + set_request_domain_by_org(self.context.get('request'), self.default_org) + return relative_url_to_absolute_url(f'/courses/{course_id}/', self.context.get('request')) + + def validate_tenant_id(self, tenant_id: Any) -> Any: + """Validate the tenant ID.""" + default_orgs = get_all_tenants_info().get('default_org_per_tenant', {}) + if tenant_id not in default_orgs: + raise serializers.ValidationError( + f'Invalid tenant_id: {tenant_id}. This tenant does not exist or is not configured properly.' + ) + + if not default_orgs[tenant_id]: + raise serializers.ValidationError( + f'No default organization configured for tenant_id: {tenant_id}.' + ) + if tenant_id not in get_org_to_tenant_map().get(default_orgs[tenant_id], []): + raise serializers.ValidationError( + f'Invalid default organization ({default_orgs[tenant_id]}) configured for tenant ID {tenant_id}.' + ) + self._default_org = default_orgs[tenant_id] + return tenant_id + + def validate_number(self, value: str) -> str: + """Validate that number matches COURSE_NUMBER_PATTERN.""" + if not self.COURSE_NUMBER_PATTERN.match(value): + raise serializers.ValidationError( + f'Invalid number ({value}). Only alphanumeric characters, underscores, and hyphens are allowed.' + ) + return value + + def validate_run(self, value: str) -> str: + """Validate that run matches COURSE_NUMBER_PATTERN.""" + if not self.COURSE_NUMBER_PATTERN.match(value): + raise serializers.ValidationError( + f'Invalid run ({value}). Only alphanumeric characters, underscores, and hyphens are allowed.' + ) + return value + + def validate(self, attrs: dict) -> dict: + """Validate the course creation data.""" + number = attrs.get('number', '') + run = attrs.get('run', '') + if len(f'course-v1:{self.default_org}+{number}+{run}') > self.MAX_COURSE_ID_LENGTH: + raise serializers.ValidationError( + f'Course ID is too long. The maximum length is {self.MAX_COURSE_ID_LENGTH} characters.' + ) + + dates = { + 'start': attrs.get('start', CourseFields.start.default), + 'end': attrs.get('end'), + 'enrollment_start': attrs.get('enrollment_start', CourseFields.enrollment_start.default), + 'enrollment_end': attrs.get('enrollment_end'), + } + greater_or_equal_rules = [ + ('end', 'start'), + ('enrollment_end', 'enrollment_start'), + ('start', 'enrollment_start'), + ('end', 'enrollment_end'), + ] + for rule in greater_or_equal_rules: + lvalue, rvalue = rule + if dates[lvalue] and dates[rvalue] and dates[lvalue] < dates[rvalue]: + raise serializers.ValidationError( + f'{lvalue} cannot be before {rvalue}. {lvalue}[{dates[lvalue]}], {rvalue}[{dates[rvalue]}]' + ) + + return attrs + + @staticmethod + def update_course_discussions_settings(course: Any) -> None: + """ + Add course discussion settings to the course. + CMS References: cms.djangoapps.contentstore.utils.update_course_discussions_settings + """ + provider = DiscussionsConfiguration.get(context_key=course.id).provider_type + course.discussions_settings['provider_type'] = provider + modulestore().update_item(course, course.published_by) + + @staticmethod + def initialize_permissions(course: Any, user: get_user_model) -> None: + """ + seeds permissions, enrolls the user, and assigns the default role for the course. + + CMS Reference: cms.djangoapps.contentstore.utils.initialize_permissions + """ + seed_permissions_roles(course.id) + CourseEnrollment.enroll(user, course.id) + assign_default_role(course.id, user) + + @staticmethod + def add_roles_and_permissions(course: Any, user: get_user_model) -> None: + """ + Assigns instructor and staff roles and required permissions + """ + CourseInstructorRole(course.id).add_users(user) + add_users(user, CourseStaffRole(course.id), user) + CourseCreateSerializer.initialize_permissions(course, user) + + def create(self, validated_data: dict) -> Any: + """ + Create new course. + + TODO: Update code to create rerun. + """ + user = self.context['request'].user + tenant_id = validated_data['tenant_id'] + org = get_all_tenants_info()['default_org_per_tenant'][tenant_id] + number = validated_data.get('number') + run = validated_data['run'] + + field_names = [ + 'start', + 'end', + 'enrollment_start', + 'enrollment_end', + 'language', + 'self_paced', + 'invitation_only', + 'display_name', + ] + fields = { + field_name: validated_data.get(field_name) for field_name in field_names if field_name in validated_data + } + + try: + org_data = ensure_organization(org) + except Exception as exc: + raise serializers.ValidationError( + 'Organization does not exist. Please add the organization before proceeding.' + ) from exc + + try: + store = modulestore().default_modulestore.get_modulestore_type() + with modulestore().default_store(store): + new_course = modulestore().create_course( + org, + number, + run, + user.id, + fields=fields, + ) + self.add_roles_and_permissions(new_course, user) + add_organization_course(org_data, new_course.id) + self.update_course_discussions_settings(new_course) + except DuplicateCourseError as exc: + raise serializers.ValidationError( + f'Course with org: {org}, number: {number}, run: {run} already exists.' + ) from exc + + return new_course + + def update(self, instance: Any, validated_data: Any) -> Any: + """Not implemented: Update an existing object.""" + raise ValueError('This serializer does not support update.') + + +class LearnerCoursesDetailsSerializer(CourseDetailsBaseSerializer): + """Serializer for learner's courses details.""" + enrollment_date = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) + last_activity = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) + certificate_url = serializers.SerializerMethodField() + progress_url = serializers.SerializerMethodField() + grades_url = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + grade = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = CourseDetailsBaseSerializer.Meta.fields + [ + 'enrollment_date', + 'last_activity', + 'certificate_url', + 'progress_url', + 'grades_url', + 'progress', + 'grade', + ] + + def get_certificate_url(self, obj: CourseOverview) -> Any: + """Return the certificate URL.""" + user = get_user_model().objects.get(id=obj.related_user_id) + return get_certificate_url(self.context.get('request'), user, obj.id) + + def get_progress_url(self, obj: CourseOverview) -> Any: + """Return the certificate URL.""" + set_request_domain_by_org(self.context.get('request'), obj.org) + return relative_url_to_absolute_url( + f'/learning/course/{obj.id}/progress/{obj.related_user_id}/', + self.context.get('request') + ) + + def get_grades_url(self, obj: CourseOverview) -> Any: + """Return the certificate URL.""" + set_request_domain_by_org(self.context.get('request'), obj.org) + return relative_url_to_absolute_url( + f'/gradebook/{obj.id}/', + self.context.get('request') + ) + + def get_progress(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the certificate URL.""" + user = get_user_model().objects.get(id=obj.related_user_id) + return get_course_blocks_completion_summary(obj.id, user) + + def get_grade(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use + """Return the grade summary.""" + collected_block_structure = get_block_structure_manager(obj.id).get_collected() + course_grade = CourseGradeFactory().read( + get_user_model().objects.get(id=obj.related_user_id), + collected_block_structure=collected_block_structure + ) + + return { + 'percent': course_grade.percent, + 'letter_grade': course_grade.letter_grade, + 'is_passing': course_grade.passed, + } + + +class LibrarySerializer(serializers.Serializer): + """Serializer for library.""" + library_key = serializers.CharField(source='location.library_key', read_only=True) + edited_by = serializers.IntegerField(source='_edited_by', read_only=True) + edited_on = serializers.DateTimeField(source='_edited_on', read_only=True) + tenant_ids = serializers.SerializerMethodField(read_only=True) + display_name = serializers.CharField() + tenant_id = serializers.IntegerField(write_only=True) + number = serializers.CharField(write_only=True) + + def get_tenant_ids(self, obj: Any) -> Any: # pylint: disable=no-self-use + """Return the tenant IDs.""" + return get_tenants_by_org(obj.location.library_key.org) + + def validate_tenant_id(self, tenant_id: Any) -> Any: # pylint: disable=no-self-use + """Validate the tenant ID.""" + default_orgs = get_all_tenants_info().get('default_org_per_tenant', {}) + if tenant_id not in default_orgs: + raise serializers.ValidationError( + f'Invalid tenant_id: "{tenant_id}". This tenant does not exist or is not configured properly.' + ) + if not default_orgs[tenant_id]: + raise serializers.ValidationError( + f'No default organization configured for tenant_id: "{tenant_id}".' + ) + if tenant_id not in get_org_to_tenant_map().get(default_orgs[tenant_id], []): + raise serializers.ValidationError( + f'Invalid default organization "{default_orgs[tenant_id]}" configured for tenant ID "{tenant_id}". ' + 'This organization is not associated with the tenant.' + ) + return tenant_id + + def create(self, validated_data: Any) -> Any: + """Create new library object.""" + user = self.context['request'].user + tenant_id = validated_data['tenant_id'] + org = get_all_tenants_info()['default_org_per_tenant'][tenant_id] + try: + store = modulestore() + with store.default_store(ModuleStoreEnum.Type.split): + library = store.create_library( + org=org, + library=validated_data['number'], + user_id=user.id, + fields={ + 'display_name': validated_data['display_name'] + }, + ) + # can't use auth.add_users here b/c it requires user to already have Instructor perms in this course + CourseInstructorRole(library.location.library_key).add_users(user) + add_users(user, CourseStaffRole(library.location.library_key), user) + return library + except DuplicateCourseError as exc: + raise serializers.ValidationError( + f'Library with org: {org} and number: {validated_data["number"]} already exists.' + ) from exc + + def update(self, instance: Any, validated_data: Any) -> Any: + """Not implemented: Update an existing object.""" + raise ValueError('This serializer does not support update.') + + def to_representation(self, instance: Any) -> dict: + """Return representation.""" + instance.org = instance.location.library_key.org + rep = super().to_representation(instance) + return rep + + +class CoursesFeedbackSerializer(serializers.ModelSerializer): + """Serializer for courses feedback.""" + course_id = serializers.SerializerMethodField() + course_name = serializers.SerializerMethodField() + author_username = serializers.SerializerMethodField() + author_full_name = serializers.SerializerMethodField() + author_altternative_full_name = serializers.SerializerMethodField() + author_email = serializers.SerializerMethodField() + + class Meta: + model = FeedbackCourse + fields = ( + 'id', 'course_id', 'course_name', 'author_username', 'author_full_name', 'author_altternative_full_name', + 'author_email', 'rating_content', 'feedback', 'public', 'rating_instructors', 'recommended' + ) + + def get_course_id(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Get course id""" + return str(obj.course_id.id) + + def get_course_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Get course id""" + return obj.course_id.display_name + + def get_author_username(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Get course id""" + return str(obj.author.username) + + def get_author_email(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Get course id""" + return str(obj.author.email) + + def get_author_full_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Return full name.""" + return extract_full_name_from_user(obj.author) + + def get_author_altternative_full_name(self, obj: FeedbackCourse) -> str: # pylint: disable=no-self-use + """Return alternative full name.""" + return ( + extract_arabic_name_from_user(obj.author) or + extract_full_name_from_user(obj.author, alternative=True) + ) diff --git a/futurex_openedx_extensions/dashboard/serializers/learners.py b/futurex_openedx_extensions/dashboard/serializers/learners.py new file mode 100644 index 00000000..a404101d --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/learners.py @@ -0,0 +1,468 @@ +"""Learner-related serializers for the dashboard API.""" +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Tuple + +from common.djangoapps.student.models import CourseEnrollment +from django.conf import settings +from django.contrib.auth import get_user_model +from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary +from lms.djangoapps.grades.context import grading_context_for_course +from lms.djangoapps.grades.models import PersistentSubsectionGrade +from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer +from openedx.core.lib.courses import get_course_by_id +from rest_framework import serializers +from social_django.models import UserSocialAuth + +from futurex_openedx_extensions.dashboard.custom_serializers import ( + ModelSerializerOptionalFields, + SerializerOptionalMethodField, +) +from futurex_openedx_extensions.helpers.certificates import get_certificate_date, get_certificate_url +from futurex_openedx_extensions.helpers.converters import dt_to_str, relative_url_to_absolute_url +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.extractors import ( + extract_arabic_name_from_user, + extract_full_name_from_user, + import_from_path, +) +from futurex_openedx_extensions.helpers.tenants import ( + get_all_tenants_info, + get_org_to_tenant_map, + get_sso_sites, +) + +logger = logging.getLogger(__name__) + + +class LearnerBasicDetailsSerializer(ModelSerializerOptionalFields): + """Serializer for learner's basic details.""" + user_id = serializers.SerializerMethodField(help_text='User ID in edx-platform') + full_name = serializers.SerializerMethodField(help_text='Full name of the user') + alternative_full_name = serializers.SerializerMethodField(help_text='Arabic name (if available)') + username = serializers.SerializerMethodField(help_text='Username of the user in edx-platform') + national_id = serializers.SerializerMethodField(help_text='National ID of the user (if available)') + email = serializers.SerializerMethodField(help_text='Email of the user in edx-platform') + mobile_no = serializers.SerializerMethodField(help_text='Mobile number of the user (if available)') + year_of_birth = serializers.SerializerMethodField(help_text='Year of birth of the user (if available)') + gender = serializers.SerializerMethodField(help_text='Gender code of the user (if available)') + gender_display = serializers.SerializerMethodField(help_text='Gender of the user (if available)') + date_joined = serializers.SerializerMethodField( + help_text='Date when the user was registered in the platform regardless of which tenant', + ) + last_login = serializers.SerializerMethodField(help_text='Date when the user last logged in') + + class Meta: + model = get_user_model() + fields = [ + 'user_id', + 'full_name', + 'alternative_full_name', + 'username', + 'national_id', + 'email', + 'mobile_no', + 'year_of_birth', + 'gender', + 'gender_display', + 'date_joined', + 'last_login', + ] + + def _get_user(self, obj: Any = None) -> get_user_model | None: # pylint: disable=no-self-use + """ + Retrieve the associated user for the given object. + + This method can be overridden in child classes to provide a different + implementation for accessing the user, depending on how the user is + related to the object (e.g., `obj.user`, `obj.profile.user`, etc.). + """ + return obj + + def _get_profile_field(self: Any, obj: get_user_model, field_name: str) -> Any: + """Get the profile field value.""" + user = self._get_user(obj) + return getattr(user.profile, field_name) if hasattr(user, 'profile') and user.profile else None + + def _get_extra_field(self: Any, obj: get_user_model, field_name: str) -> Any: + """Get the extra field value.""" + user = self._get_user(obj) + return getattr(user.extrainfo, field_name) if hasattr(user, 'extrainfo') and user.extrainfo else None + + def get_user_id(self, obj: get_user_model) -> int: + """Return user ID.""" + return self._get_user(obj).id # type: ignore + + def get_email(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).email # type: ignore + + def get_username(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).username # type: ignore + + def get_date_joined(self, obj: Any) -> str | None: + date_joined = self._get_user(obj).date_joined # type: ignore + return dt_to_str(date_joined) + + def get_last_login(self, obj: Any) -> str | None: + last_login = self._get_user(obj).last_login # type: ignore + return dt_to_str(last_login) + + def get_national_id(self, obj: get_user_model) -> Any: + """Return national ID.""" + return self._get_extra_field(obj, 'national_id') + + def get_full_name(self, obj: get_user_model) -> Any: + """Return full name.""" + return extract_full_name_from_user(self._get_user(obj)) + + def get_alternative_full_name(self, obj: get_user_model) -> Any: + """Return alternative full name.""" + return ( + extract_arabic_name_from_user(self._get_user(obj)) or + extract_full_name_from_user(self._get_user(obj), alternative=True) + ) + + def get_mobile_no(self, obj: get_user_model) -> Any: + """Return mobile number.""" + return self._get_profile_field(obj, 'phone_number') + + def get_gender(self, obj: get_user_model) -> Any: + """Return gender.""" + return self._get_profile_field(obj, 'gender') + + def get_gender_display(self, obj: get_user_model) -> Any: + """Return readable text for gender""" + return self._get_profile_field(obj, 'gender_display') + + def get_year_of_birth(self, obj: get_user_model) -> Any: + """Return year of birth.""" + return self._get_profile_field(obj, 'year_of_birth') + + +class CourseScoreAndCertificateSerializer(ModelSerializerOptionalFields): + """ + Course Score and Certificate Details Serializer + + Handles data for multiple courses, but exam_scores is included only for a single course when course_id is + provided in the context. Otherwise, exam_scores is excluded, even if requested in requested_optional_field_tags. + """ + exam_scores = SerializerOptionalMethodField(field_tags=['exam_scores', 'csv_export']) + certificate_available = serializers.BooleanField() + course_score = serializers.FloatField() + active_in_course = serializers.BooleanField() + progress = SerializerOptionalMethodField(field_tags=['progress', 'csv_export']) + certificate_url = SerializerOptionalMethodField(field_tags=['certificate_url', 'csv_export']) + certificate_date = SerializerOptionalMethodField(field_tags=['certificate_date', 'csv_export']) + + class Meta: + fields = [ + 'certificate_available', + 'course_score', + 'active_in_course', + 'progress', + 'certificate_url', + 'certificate_date', + 'exam_scores', + ] + + def __init__(self, *args: Any, **kwargs: Any): + """Initialize the serializer.""" + super().__init__(*args, **kwargs) + self._is_exam_name_in_header = self.context.get('omit_subsection_name', '0') != '1' + self._grading_info: Dict[str, Any] = {} + self._subsection_locations: Dict[str, Any] = {} + + if self.context.get('course_id'): + self.collect_grading_info() + + def collect_grading_info(self) -> None: + """ + Collect the grading info. + """ + course_id = CourseLocator.from_string(self.context.get('course_id')) + self._grading_info = {} + self._subsection_locations = {} + if not self.is_optional_field_requested('exam_scores'): + return + + grading_context = grading_context_for_course(get_course_by_id(course_id)) + index = 0 + for assignment_type_name, subsection_infos in grading_context['all_graded_subsections_by_type'].items(): + for subsection_index, subsection_info in enumerate(subsection_infos, start=1): + header_enum = f' {subsection_index}' if len(subsection_infos) > 1 else '' + header_name = f'{assignment_type_name}{header_enum}' + if self.is_exam_name_in_header: + header_name += f': {subsection_info["subsection_block"].display_name}' + + self._grading_info[str(index)] = { + 'header_name': header_name, + 'location': str(subsection_info['subsection_block'].location), + } + self._subsection_locations[str(subsection_info['subsection_block'].location)] = str(index) + index += 1 + + @property + def is_exam_name_in_header(self) -> bool: + """Check if the exam name is needed in the header.""" + return self._is_exam_name_in_header + + @property + def grading_info(self) -> Dict[str, Any]: + """Get the grading info.""" + return self._grading_info + + @property + def subsection_locations(self) -> Dict[str, Any]: + """Get the subsection locations.""" + return self._subsection_locations + + def _get_course_id(self, obj: Any = None) -> Any: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + raise NotImplementedError('Child class must implement _get_user method.') + + def _get_user(self, obj: Any = None) -> Any: + """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" + raise NotImplementedError('Child class must implement _get_course_id method.') + + def get_certificate_url(self, obj: Any) -> Any: + """Return the certificate URL.""" + return get_certificate_url( + self.context.get('request'), self._get_user(obj), self._get_course_id(obj) + ) + + def get_certificate_date(self, obj: Any) -> Any: + """Return the certificate Date.""" + return dt_to_str(get_certificate_date( + self._get_user(obj), self._get_course_id(obj) + )) + + def get_progress(self, obj: Any) -> Any: + """Return the certificate URL.""" + progress_info = get_course_blocks_completion_summary( + self._get_course_id(obj), self._get_user(obj) + ) + total = progress_info['complete_count'] + progress_info['incomplete_count'] + progress_info['locked_count'] + return round(progress_info['complete_count'] / total, 4) if total else 0.0 + + def get_exam_scores(self, obj: Any) -> Dict[str, Tuple[float, float] | None]: + """Return exam scores.""" + result: Dict[str, Tuple[float, float] | None] = {__index: None for __index in self.grading_info} + grades = PersistentSubsectionGrade.objects.filter( + user_id=self._get_user(obj).id, + course_id=self._get_course_id(obj), + usage_key__in=self.subsection_locations.keys(), + first_attempted__isnull=False, + ).values('usage_key', 'earned_all', 'possible_all') + + for grade in grades: + result[self.subsection_locations[str(grade['usage_key'])]] = (grade['earned_all'], grade['possible_all']) + + return result + + def to_representation(self, instance: Any) -> Any: + """Return the representation of the instance.""" + def _extract_exam_scores(representation_item: dict[str, Any]) -> None: + exam_scores = representation_item.pop('exam_scores', {}) + for index, score in exam_scores.items(): + earned_key = f'earned - {self.grading_info[index]["header_name"]}' + possible_key = f'possible - {self.grading_info[index]["header_name"]}' + representation_item[earned_key] = score[0] if score else 'no attempt' + representation_item[possible_key] = score[1] if score else 'no attempt' + + representation = super().to_representation(instance) + + _extract_exam_scores(representation) + + return representation + + +class LearnerDetailsSerializer(LearnerBasicDetailsSerializer): + """Serializer for learner details.""" + enrolled_courses_count = serializers.SerializerMethodField(help_text='Number of courses the user is enrolled in') + certificates_count = serializers.SerializerMethodField(help_text='Number of certificates the user has earned') + + class Meta: + model = get_user_model() + fields = LearnerBasicDetailsSerializer.Meta.fields + [ + 'enrolled_courses_count', + 'certificates_count', + ] + + def get_certificates_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use + """Return certificates count.""" + return obj.certificates_count + + def get_enrolled_courses_count(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use + """Return enrolled courses count.""" + return obj.courses_count + + +class LearnerDetailsForCourseSerializer( + LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer +): # pylint: disable=too-many-ancestors + """Serializer for learner details for a course.""" + + class Meta: + model = get_user_model() + fields = LearnerBasicDetailsSerializer.Meta.fields + CourseScoreAndCertificateSerializer.Meta.fields + + def _get_course_id(self, obj: Any = None) -> CourseLocator: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + return CourseLocator.from_string(self.context.get('course_id')) + + def _get_user(self, obj: Any = None) -> get_user_model: + """Get the User. Its helper method required for CourseScoreAndCertificateSerializer""" + return obj + + +class LearnerEnrollmentSerializer( + LearnerBasicDetailsSerializer, CourseScoreAndCertificateSerializer +): # pylint: disable=too-many-ancestors + """Serializer for learner enrollments""" + course_id = serializers.SerializerMethodField() + sso_external_id = SerializerOptionalMethodField(field_tags=['sso_external_id', 'csv_export']) + + class Meta: + model = CourseEnrollment + fields = ( + LearnerBasicDetailsSerializer.Meta.fields + + CourseScoreAndCertificateSerializer.Meta.fields + + ['course_id', 'sso_external_id'] + ) + + def _get_course_id(self, obj: Any = None) -> CourseLocator | None: + """Get the course ID. Its helper method required for CourseScoreAndCertificateSerializer""" + return obj.course_id if obj else None + + def _get_user(self, obj: Any = None) -> get_user_model | None: + """ + Get the User. Its helper method required for CourseScoreAndCertificateSerializer. It also + plays important role for LearnerBasicDetailsSerializer + """ + return obj.user if obj else None + + def get_course_id(self, obj: Any) -> str: + """Get course id""" + return str(self._get_course_id(obj)) + + @staticmethod + def get_sso_site_info(obj: Any) -> List[Dict[str, Any]]: + """Get SSO information of the tenant's site related to the course""" + course_tenants = get_org_to_tenant_map().get(obj.course_id.org.lower(), []) + for tenant_id in course_tenants: + sso_site_info = get_sso_sites().get(get_all_tenants_info()['sites'][tenant_id]) + if sso_site_info: + return sso_site_info + + return [] + + def get_sso_external_id(self, obj: Any) -> str: + """Get the SSO external ID from social auth extra_data.""" + result = '' + + sso_site_info = self.get_sso_site_info(obj) + if not sso_site_info: + return result + + social_auth_records = UserSocialAuth.objects.filter(user=obj.user, provider='tpa-saml') + user_auth_by_slug = {} + for record in social_auth_records: + if record.uid.count(':') == 1: + sso_slug, _ = record.uid.split(':') + user_auth_by_slug[sso_slug] = record + + if not user_auth_by_slug: + return result + + for entity_id, sso_info in settings.FX_SSO_INFO.items(): + if not sso_info.get('external_id_field') or not sso_info.get('external_id_extractor'): + logger.warning( + 'Bad (external_id_field) or (external_id_extractor) settings for Entity ID (%s)', entity_id, + ) + continue + + for sso_links in sso_site_info: + if entity_id == sso_links['entity_id']: + user_auth_record = user_auth_by_slug.get(sso_links['slug']) + if not user_auth_record: + continue + + external_id_value = user_auth_record.extra_data.get(sso_info['external_id_field']) + if external_id_value: + try: + external_id_extractor = import_from_path(sso_info['external_id_extractor']) + except Exception as exc: + raise FXCodedException( + code=FXExceptionCodes.BAD_CONFIGURATION_EXTERNAL_ID_EXTRACTOR, + message=f'Bad configuration: FX_SSO_INFO.{entity_id}.external_id_extractor. {str(exc)}' + ) from exc + + try: + result = str(external_id_extractor(external_id_value) or '') + except Exception as exc: + logger.warning( + 'SSO External ID extraction raised and error for user %s: %s', obj.user.username, exc, + ) + break + + return result + + +class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer): # pylint: disable=too-many-ancestors + """Serializer for extended learner details.""" + city = serializers.SerializerMethodField() + bio = serializers.SerializerMethodField() + level_of_education = serializers.SerializerMethodField() + social_links = serializers.SerializerMethodField() + image = serializers.SerializerMethodField() + profile_link = serializers.SerializerMethodField() + + class Meta: + model = get_user_model() + fields = LearnerDetailsSerializer.Meta.fields + [ + 'city', + 'bio', + 'level_of_education', + 'social_links', + 'image', + 'profile_link', + ] + + def get_city(self, obj: get_user_model) -> Any: + """Return city.""" + return self._get_profile_field(obj, 'city') + + def get_bio(self, obj: get_user_model) -> Any: + """Return bio.""" + return self._get_profile_field(obj, 'bio') + + def get_level_of_education(self, obj: get_user_model) -> Any: + """Return level of education.""" + return self._get_profile_field(obj, 'level_of_education_display') + + def get_social_links(self, obj: get_user_model) -> Any: # pylint: disable=no-self-use + """Return social links.""" + result = {} + profile = obj.profile if hasattr(obj, 'profile') else None + if profile: + links = profile.social_links.all().order_by('platform') + for link in links: + result[link.platform] = link.social_link + return result + + def get_image(self, obj: get_user_model) -> Any: + """Return image.""" + if hasattr(obj, 'profile') and obj.profile: + return AccountLegacyProfileSerializer.get_profile_image( + obj.profile, obj, self.context.get('request') + )['image_url_large'] + + return None + + def get_profile_link(self, obj: get_user_model) -> Any: + """Return profile link.""" + return relative_url_to_absolute_url(f'/u/{obj.username}/', self.context.get('request')) diff --git a/futurex_openedx_extensions/dashboard/serializers/roles.py b/futurex_openedx_extensions/dashboard/serializers/roles.py new file mode 100644 index 00000000..1c2a274d --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/roles.py @@ -0,0 +1,233 @@ +"""Role-related serializers for the dashboard API.""" +from __future__ import annotations + +from typing import Any + +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework.fields import empty + +from futurex_openedx_extensions.helpers.constants import COURSE_ACCESS_ROLES_GLOBAL +from futurex_openedx_extensions.helpers.roles import ( + RoleType, + get_course_access_roles_queryset, + get_user_course_access_roles, +) +from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org + + +class UserRolesSerializer(serializers.ModelSerializer): + """Serializer for user roles.""" + user_id = serializers.SerializerMethodField(help_text='User ID in edx-platform') + full_name = serializers.SerializerMethodField(help_text='Full name of the user') + alternative_full_name = serializers.SerializerMethodField(help_text='Arabic name (if available)') + username = serializers.SerializerMethodField(help_text='Username of the user in edx-platform') + national_id = serializers.SerializerMethodField(help_text='National ID of the user (if available)') + email = serializers.SerializerMethodField(help_text='Email of the user in edx-platform') + tenants = serializers.SerializerMethodField() + global_roles = serializers.SerializerMethodField() + + def __init__(self, instance: Any | None = None, data: Any = empty, **kwargs: Any): + """Initialize the serializer.""" + self._org_tenant: dict[str, list[int]] = {} + self._roles_data: dict[Any, Any] = {} + + permission_info = kwargs['context']['request'].fx_permission_info + self.orgs_filter = permission_info['view_allowed_any_access_orgs'] + self.permitted_tenant_ids = permission_info['view_allowed_tenant_ids_any_access'] + self.query_params = self.parse_query_params(kwargs['context']['request'].query_params) + + if instance: + self.construct_roles_data(instance if isinstance(instance, list) else [instance]) + + super().__init__(instance, data, **kwargs) + + @staticmethod + def parse_query_params(query_params: dict[str, Any]) -> dict[str, Any]: + """ + Parse the query parameters. + + :param query_params: The query parameters. + :type query_params: dict[str, Any] + """ + result = { + 'search_text': query_params.get('search_text', ''), + 'course_ids_filter': query_params[ + 'only_course_ids' + ].split(',') if query_params.get('only_course_ids') else [], + 'roles_filter': query_params.get('only_roles', '').split(',') if query_params.get('only_roles') else [], + 'include_hidden_roles': query_params.get('include_hidden_roles', '0') == '1', + } + + if query_params.get('active_users_filter') is not None: + result['active_filter'] = query_params['active_users_filter'] == '1' + else: + result['active_filter'] = None + + excluded_role_types = query_params.get('excluded_role_types', '').split(',') \ + if query_params.get('excluded_role_types') else [] + + result['excluded_role_types'] = [] + if 'global' in excluded_role_types: + result['excluded_role_types'].append(RoleType.GLOBAL) + + if 'tenant' in excluded_role_types: + result['excluded_role_types'].append(RoleType.ORG_WIDE) + + if 'course' in excluded_role_types: + result['excluded_role_types'].append(RoleType.COURSE_SPECIFIC) + + return result + + def get_org_tenants(self, org: str) -> list[int]: + """ + Get the tenants for an organization. + + :param org: The organization to get the tenants for. + :type org: str + :return: The tenants. + :rtype: list[int] + """ + result = self._org_tenant.get(org) + if not result: + result = get_tenants_by_org(org) + self._org_tenant[org] = result + + return result or [] + + def construct_roles_data(self, users: list[get_user_model]) -> None: + """ + Construct the roles data. + + { + "": { + "": { + "tenant_roles": ["", ""], + "course_roles": { + "": ["", ""], + "": ["", ""], + }, + }, + .... + }, + .... + } + + :param users: The user instances. + :type users: list[get_user_model] + """ + self._roles_data = {} + for user in users: + self._roles_data[user.id] = {} + + records = get_course_access_roles_queryset( + self.orgs_filter, + remove_redundant=True, + users=users, + search_text=self.query_params['search_text'], + roles_filter=self.query_params['roles_filter'], + active_filter=self.query_params['active_filter'], + course_ids_filter=self.query_params['course_ids_filter'], + excluded_role_types=self.query_params['excluded_role_types'], + excluded_hidden_roles=not self.query_params['include_hidden_roles'], + ) + + for record in records or []: + usr_data = self._roles_data[record.user_id] + for tenant_id in self.get_org_tenants(record.org): + if tenant_id not in self.permitted_tenant_ids: + continue + + if tenant_id not in usr_data: + usr_data[tenant_id] = { + 'tenant_roles': [], + 'course_roles': {}, + } + + course_id = str(record.course_id) if record.course_id else None + if course_id and course_id not in usr_data[tenant_id]['course_roles']: + usr_data[tenant_id]['course_roles'][course_id] = [] + + if course_id: + usr_data[tenant_id]['course_roles'][course_id].append(record.role) + elif record.role not in usr_data[tenant_id]['tenant_roles']: + usr_data[tenant_id]['tenant_roles'].append(record.role) + + @property + def roles_data(self) -> dict[Any, Any] | None: + """Get the roles data.""" + return self._roles_data + + def _get_user(self, obj: Any = None) -> get_user_model | None: # pylint: disable=no-self-use + """ + Retrieve the associated user for the given object. + + This method can be overridden in child classes to provide a different + implementation for accessing the user, depending on how the user is + related to the object (e.g., `obj.user`, `obj.profile.user`, etc.). + """ + return obj + + def _get_profile_field(self: Any, obj: get_user_model, field_name: str) -> Any: + """Get the profile field value.""" + user = self._get_user(obj) + return getattr(user.profile, field_name) if hasattr(user, 'profile') and user.profile else None + + def _get_extra_field(self: Any, obj: get_user_model, field_name: str) -> Any: + """Get the extra field value.""" + user = self._get_user(obj) + return getattr(user.extrainfo, field_name) if hasattr(user, 'extrainfo') and user.extrainfo else None + + def get_user_id(self, obj: get_user_model) -> int: + """Return user ID.""" + return self._get_user(obj).id # type: ignore + + def get_email(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).email # type: ignore + + def get_username(self, obj: get_user_model) -> str: + """Return user ID.""" + return self._get_user(obj).username # type: ignore + + def get_national_id(self, obj: get_user_model) -> Any: + """Return national ID.""" + return self._get_extra_field(obj, 'national_id') + + def get_full_name(self, obj: get_user_model) -> Any: + """Return full name.""" + from futurex_openedx_extensions.helpers.extractors import extract_full_name_from_user + return extract_full_name_from_user(self._get_user(obj)) + + def get_alternative_full_name(self, obj: get_user_model) -> Any: + """Return alternative full name.""" + from futurex_openedx_extensions.helpers.extractors import ( + extract_arabic_name_from_user, + extract_full_name_from_user, + ) + return ( + extract_arabic_name_from_user(self._get_user(obj)) or + extract_full_name_from_user(self._get_user(obj), alternative=True) + ) + + def get_tenants(self, obj: get_user_model) -> Any: + """Return the tenants.""" + return self.roles_data.get(obj.id, {}) if self.roles_data else {} + + def get_global_roles(self, obj: get_user_model) -> Any: # pylint:disable=no-self-use + """Return the global roles.""" + roles_dict = get_user_course_access_roles(obj)['roles'] + return [role for role in roles_dict if role in COURSE_ACCESS_ROLES_GLOBAL] + + class Meta: + model = get_user_model() + fields = [ + 'user_id', + 'email', + 'username', + 'national_id', + 'full_name', + 'alternative_full_name', + 'global_roles', + 'tenants', + ] diff --git a/futurex_openedx_extensions/dashboard/serializers/statistics.py b/futurex_openedx_extensions/dashboard/serializers/statistics.py new file mode 100644 index 00000000..ba1ef8af --- /dev/null +++ b/futurex_openedx_extensions/dashboard/serializers/statistics.py @@ -0,0 +1,50 @@ +"""Statistics-related serializers for the dashboard API.""" +from __future__ import annotations + +from rest_framework import serializers + +from futurex_openedx_extensions.helpers.converters import DEFAULT_DATETIME_FORMAT + + +class ReadOnlySerializer(serializers.Serializer): # pylint: disable=abstract-method + """Serializer for read-only endpoints.""" + + def create(self, validated_data: any) -> any: + """Not implemented: Create a new object.""" + raise ValueError('This serializer is read-only and does not support object creation.') + + def update(self, instance: any, validated_data: any) -> any: + """Not implemented: Update an existing object.""" + raise ValueError('This serializer is read-only and does not support object updates.') + + +class AggregatedCountsQuerySettingsSerializer(ReadOnlySerializer): + """Serializer for aggregated counts settings.""" + aggregate_period = serializers.CharField() + date_from = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) + date_to = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT) + + +class AggregatedCountsTotalsSerializer(ReadOnlySerializer): + enrollments_count = serializers.IntegerField(required=False, allow_null=True) + + +class AggregatedCountsValuesSerializer(ReadOnlySerializer): + label = serializers.CharField() + value = serializers.IntegerField() + + +class AggregatedCountsAllTenantsSerializer(ReadOnlySerializer): + enrollments_count = AggregatedCountsValuesSerializer(required=False, allow_null=True, many=True) + totals = AggregatedCountsTotalsSerializer() + + +class AggregatedCountsOneTenantSerializer(AggregatedCountsAllTenantsSerializer): + tenant_id = serializers.IntegerField() + + +class AggregatedCountsSerializer(ReadOnlySerializer): + query_settings = AggregatedCountsQuerySettingsSerializer() + all_tenants = AggregatedCountsAllTenantsSerializer() + by_tenant = AggregatedCountsOneTenantSerializer(many=True) + limited_access = serializers.BooleanField() diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py deleted file mode 100644 index af89342b..00000000 --- a/futurex_openedx_extensions/dashboard/views.py +++ /dev/null @@ -1,1780 +0,0 @@ -"""Views for the dashboard app""" -# pylint: disable=too-many-lines -from __future__ import annotations - -import json -import os -import re -import uuid -from datetime import date, datetime, timedelta -from typing import Any, Dict -from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit - -from common.djangoapps.student.models import get_user_by_username_or_email -from dateutil.relativedelta import relativedelta -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.core.paginator import EmptyPage -from django.db import transaction -from django.db.models.query import QuerySet -from django.http import JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django_filters.rest_framework import DjangoFilterBackend -from drf_yasg.utils import swagger_auto_schema -from edx_api_doc_tools import exclude_schema_for -from rest_framework import status as http_status -from rest_framework import viewsets -from rest_framework.exceptions import ParseError, PermissionDenied -from rest_framework.generics import ListAPIView -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.views import APIView - -from futurex_openedx_extensions.dashboard import serializers -from futurex_openedx_extensions.dashboard.details.courses import ( - get_courses_feedback_queryset, - get_courses_queryset, - get_learner_courses_info_queryset, -) -from futurex_openedx_extensions.dashboard.details.learners import ( - get_learner_info_queryset, - get_learners_by_course_queryset, - get_learners_enrollments_queryset, - get_learners_queryset, -) -from futurex_openedx_extensions.dashboard.docs_utils import docs -from futurex_openedx_extensions.dashboard.statistics.certificates import ( - get_certificates_count, - get_learning_hours_count, -) -from futurex_openedx_extensions.dashboard.statistics.courses import ( - get_courses_count, - get_courses_count_by_status, - get_courses_ratings, - get_enrollments_count, - get_enrollments_count_aggregated, -) -from futurex_openedx_extensions.dashboard.statistics.learners import get_learners_count -from futurex_openedx_extensions.helpers import clickhouse_operations as ch -from futurex_openedx_extensions.helpers.constants import ( - ALLOWED_FILE_EXTENSIONS, - CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS, - CLICKHOUSE_FX_BUILTIN_ORG_IN_TENANTS, - CONFIG_FILES_UPLOAD_DIR, - COURSE_ACCESS_ROLES_STAFF_EDITOR, - COURSE_ACCESS_ROLES_SUPPORTED_READ, - COURSE_STATUS_SELF_PREFIX, - COURSE_STATUSES, - FX_VIEW_DEFAULT_AUTH_CLASSES, -) -from futurex_openedx_extensions.helpers.converters import dict_to_hash, error_details_to_dictionary -from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes -from futurex_openedx_extensions.helpers.export_mixins import ExportCSVMixin -from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter, DefaultSearchFilter -from futurex_openedx_extensions.helpers.library import get_accessible_libraries -from futurex_openedx_extensions.helpers.models import ClickhouseQuery, ConfigAccessControl, DataExportTask, TenantAsset -from futurex_openedx_extensions.helpers.pagination import DefaultPagination -from futurex_openedx_extensions.helpers.permissions import ( - FXHasTenantAllCoursesAccess, - FXHasTenantCourseAccess, - IsAnonymousOrSystemStaff, - IsSystemStaff, - get_tenant_limited_fx_permission_info, -) -from futurex_openedx_extensions.helpers.roles import ( - FXViewRoleInfoMixin, - add_course_access_roles, - delete_course_access_roles, - get_accessible_tenant_ids, - get_course_access_roles_queryset, - get_usernames_with_access_roles, - update_course_access_roles, -) -from futurex_openedx_extensions.helpers.tenants import ( - create_new_tenant_config, - delete_draft_tenant_config, - get_accessible_config_keys, - get_all_tenants_info, - get_draft_tenant_config, - get_excluded_tenant_ids, - get_tenant_config, - get_tenants_info, - publish_tenant_config, - update_draft_tenant_config, -) -from futurex_openedx_extensions.helpers.upload import get_storage_dir, upload_file -from futurex_openedx_extensions.helpers.users import get_user_by_key - -default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() - - -@docs('TotalCountsView.get') -class TotalCountsView(FXViewRoleInfoMixin, APIView): - """ - View to get the total count statistics - - TODO: there is a better way to get info per tenant without iterating over all tenants - """ - STAT_CERTIFICATES = 'certificates' - STAT_COURSES = 'courses' - STAT_ENROLLMENTS = 'enrollments' - STAT_HIDDEN_COURSES = 'hidden_courses' - STAT_LEARNERS = 'learners' - STAT_LEARNING_HOURS = 'learning_hours' - STAT_UNIQUE_LEARNERS = 'unique_learners' - - STAT_RESULT_KEYS = { - STAT_CERTIFICATES: 'certificates_count', - STAT_COURSES: 'courses_count', - STAT_ENROLLMENTS: 'enrollments_count', - STAT_HIDDEN_COURSES: 'hidden_courses_count', - STAT_LEARNERS: 'learners_count', - STAT_LEARNING_HOURS: 'learning_hours_count', - STAT_UNIQUE_LEARNERS: 'unique_learners', - } - - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'total_counts_statistics' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/statistics/v1/total_counts/: Get the total count statistics' - - def __init__(self, **kwargs: Any) -> None: - """Initialize the view""" - super().__init__() - self.valid_stats = [ - self.STAT_CERTIFICATES, self.STAT_COURSES, self.STAT_ENROLLMENTS, self.STAT_HIDDEN_COURSES, - self.STAT_LEARNERS, self.STAT_LEARNING_HOURS, self.STAT_UNIQUE_LEARNERS, - ] - self.stats: list[str] = [] - self.include_staff = False - self.tenant_ids: list[int] = [] - - def _get_certificates_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of certificates for the given tenant""" - collector_result = get_certificates_count(one_tenant_permission_info, include_staff=self.include_staff) - return sum(certificate_count for certificate_count in collector_result.values()) - - @staticmethod - def _get_courses_count_data(one_tenant_permission_info: dict, visible_filter: bool | None) -> int: - """Get the count of courses for the given tenant""" - collector_result = get_courses_count(one_tenant_permission_info, visible_filter=visible_filter) - return sum(org_count['courses_count'] for org_count in collector_result) - - def _get_enrollments_count_data(self, one_tenant_permission_info: dict, visible_filter: bool | None) -> int: - """Get the count of enrollments for the given tenant""" - collector_result = get_enrollments_count( - one_tenant_permission_info, visible_filter=visible_filter, include_staff=self.include_staff, - ) - return sum(org_count['enrollments_count'] for org_count in collector_result) - - def _get_learners_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of learners for the given tenant""" - return get_learners_count(one_tenant_permission_info, include_staff=self.include_staff) - - def _get_learning_hours_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of learning_hours for the given tenant""" - return get_learning_hours_count(one_tenant_permission_info, include_staff=self.include_staff) - - def _get_stat_count(self, stat: str, tenant_id: int) -> Any: - """Get the count of the given stat for the given tenant""" - if stat == self.STAT_UNIQUE_LEARNERS: - return get_learners_count(self.fx_permission_info, self.include_staff) - - one_tenant_permission_info = get_tenant_limited_fx_permission_info(self.fx_permission_info, tenant_id) - if stat == self.STAT_CERTIFICATES: - result = self._get_certificates_count_data(one_tenant_permission_info) - - elif stat == self.STAT_COURSES: - result = self._get_courses_count_data(one_tenant_permission_info, visible_filter=True) - - elif stat == self.STAT_ENROLLMENTS: - result = self._get_enrollments_count_data(one_tenant_permission_info, visible_filter=True) - - elif stat == self.STAT_HIDDEN_COURSES: - result = self._get_courses_count_data(one_tenant_permission_info, visible_filter=False) - - elif stat == self.STAT_LEARNING_HOURS: - result = self._get_learning_hours_count_data(one_tenant_permission_info) - - else: - result = self._get_learners_count_data(one_tenant_permission_info) - - return result - - def _load_query_params(self, request: Any) -> None: - """Load the query parameters""" - self.stats = request.query_params.get('stats', '').split(',') - invalid_stats = list(set(self.stats) - set(self.valid_stats)) - if invalid_stats: - raise ParseError(f'Invalid stats type: {invalid_stats}') - self.include_staff = request.query_params.get('include_staff', '0') == '1' - self.tenant_ids = self.fx_permission_info['view_allowed_tenant_ids_any_access'] - - def _construct_result(self) -> dict: - """Construct the result dictionary""" - if self.STAT_UNIQUE_LEARNERS in self.stats: - total_unique_learners = self._get_stat_count(self.STAT_UNIQUE_LEARNERS, 0) - self.stats.remove(self.STAT_UNIQUE_LEARNERS) - else: - total_unique_learners = None - result: dict[Any, Any] = dict({tenant_id: {} for tenant_id in self.tenant_ids}) - result.update({ - f'total_{self.STAT_RESULT_KEYS[stat]}': 0 for stat in self.stats - }) - - for tenant_id in self.tenant_ids: - for stat in self.stats: - count = int(self._get_stat_count(stat, tenant_id)) - result[tenant_id][self.STAT_RESULT_KEYS[stat]] = count - result[f'total_{self.STAT_RESULT_KEYS[stat]}'] += count - - if total_unique_learners is not None: - result['total_unique_learners'] = total_unique_learners - - result['limited_access'] = self.fx_permission_info['view_allowed_course_access_orgs'] != [] - - return result - - def get(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse: - """Returns the total count statistics for the selected tenants.""" - self._load_query_params(request) - - return JsonResponse(self._construct_result()) - - -@docs('AggregatedCountsView.get') -class AggregatedCountsView(TotalCountsView): # pylint: disable=too-many-instance-attributes - """ - View to get the aggregated count statistics - """ - AGGREGATE_PERIOD_DAY = 'day' - AGGREGATE_PERIOD_MONTH = 'month' - AGGREGATE_PERIOD_QUARTER = 'quarter' - AGGREGATE_PERIOD_YEAR = 'year' - - VALID_AGGREGATE_PERIOD = [ - AGGREGATE_PERIOD_DAY, AGGREGATE_PERIOD_MONTH, AGGREGATE_PERIOD_YEAR, AGGREGATE_PERIOD_QUARTER, - ] - - fx_view_name = 'aggregated_counts_statistics' - fx_view_description = 'api/fx/statistics/v1/aggregated_counts/: Get the total count statistics with aggregate' - - def __init__(self, **kwargs: Any) -> None: - """Initialize the view""" - super().__init__() - self.valid_stats = [self.STAT_ENROLLMENTS] - self.aggregate_period = self.AGGREGATE_PERIOD_DAY - self.date_to: date | None = None - self.date_from: date | None = None - self.favors_backward = True - self.max_period_chunks = 0 - self.fill_missing_periods = True - - def _load_query_params(self, request: Any) -> None: - """Load the query parameters""" - super()._load_query_params(request) - - aggregate_period = request.query_params.get('aggregate_period') - if aggregate_period is None or aggregate_period not in self.VALID_AGGREGATE_PERIOD: - raise ParseError(f'Invalid aggregate_period: {aggregate_period}') - - self.favors_backward = request.query_params.get('favors_backward', '1') == '1' - - try: - self.max_period_chunks = int(request.query_params.get('max_period_chunks', 0)) - except ValueError as exc: - raise ParseError('Invalid max_period_chunks. It must be an integer.') from exc - - if self.max_period_chunks < 0 or self.max_period_chunks > settings.FX_MAX_PERIOD_CHUNKS_MAP[aggregate_period]: - self.max_period_chunks = 0 - - self.aggregate_period = aggregate_period - - self.fill_missing_periods = request.query_params.get('fill_missing_periods', '1') == '1' - - date_from = request.query_params.get('date_from') - date_to = request.query_params.get('date_to') - - try: - self.date_from = datetime.strptime(date_from, '%Y-%m-%d').date() if date_from else None - self.date_to = datetime.strptime(date_to, '%Y-%m-%d').date() if date_to else None - except (ValueError, TypeError) as exc: - raise ParseError( - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' - ) from exc - - def _get_certificates_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of certificates for the given tenant""" - raise NotImplementedError('Certificates count is not supported for aggregated counts yet') - - @staticmethod - def _get_courses_count_data(one_tenant_permission_info: dict, visible_filter: bool | None) -> int: - """Get the count of courses for the given tenant""" - raise NotImplementedError('Courses count is not supported for aggregated counts yet') - - def _get_enrollments_count_data( # type: ignore - self, one_tenant_permission_info: dict, visible_filter: bool | None, - ) -> tuple[list, datetime | None, datetime | None]: - """Get the count of enrollments for the given tenant""" - collector_result, calculated_from, calculated_to = get_enrollments_count_aggregated( - one_tenant_permission_info, - visible_filter=visible_filter, - include_staff=self.include_staff, - aggregate_period=self.aggregate_period, - date_from=self.date_from, - date_to=self.date_to, - favors_backward=self.favors_backward, - max_period_chunks=self.max_period_chunks, - ) - return [ - {'label': item['period'], 'value': item['enrollments_count']} for item in collector_result - ], calculated_from, calculated_to - - def _get_learners_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of learners for the given tenant""" - raise NotImplementedError('Learners count is not supported for aggregated counts yet') - - def _get_learning_hours_count_data(self, one_tenant_permission_info: dict) -> int: - """Get the count of learning_hours for the given tenant""" - raise NotImplementedError('Learning hours count is not supported for aggregated counts yet') - - @staticmethod - def get_period_label(aggregate_period: str, the_date: date | datetime) -> str: - """Get the period label""" - if not isinstance(the_date, (date, datetime)): - raise ValidationError(f'the_date must be a date or datetime object. Got ({the_date.__class__.__name__})') - - match aggregate_period: - case AggregatedCountsView.AGGREGATE_PERIOD_DAY: - result = the_date.strftime('%Y-%m-%d') - - case AggregatedCountsView.AGGREGATE_PERIOD_MONTH: - result = the_date.strftime('%Y-%m') - - case AggregatedCountsView.AGGREGATE_PERIOD_QUARTER: - result = f'{the_date.year}-Q{((the_date.month - 1) // 3) + 1}' - - case AggregatedCountsView.AGGREGATE_PERIOD_YEAR: - result = str(the_date.year) - - case _: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=f'Invalid aggregate_period: {aggregate_period}', - ) - - return result - - @staticmethod - def get_next_period_date(aggregate_period: str, the_date: date | datetime) -> date | datetime: - """Get the next period date""" - if not isinstance(the_date, (date, datetime)): - raise ValidationError(f'the_date must be a date or datetime object. Got ({the_date.__class__.__name__})') - - match aggregate_period: - case AggregatedCountsView.AGGREGATE_PERIOD_DAY: - result = the_date + timedelta(days=1) - - case AggregatedCountsView.AGGREGATE_PERIOD_MONTH: - result = the_date.replace(day=1) + relativedelta(months=1) - - case AggregatedCountsView.AGGREGATE_PERIOD_QUARTER: - result = the_date.replace(day=1).replace( - month=((the_date.month - 1) // 3) * 3 + 1, - ) + relativedelta(months=3) - - case AggregatedCountsView.AGGREGATE_PERIOD_YEAR: - result = the_date.replace(day=1, month=1) + relativedelta(years=1) - - case _: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=f'Invalid aggregate_period: {aggregate_period}', - ) - - return result - - def get_data_with_missing_periods( - self, data: list[dict[str, Any]], already_sorted: bool = False, - ) -> list[dict[str, Any]]: - """Get the date with missing periods.""" - data = sorted(data, key=lambda x: x['label']) if not already_sorted else data - - if not self.date_from or not self.date_to: - return data - - result = [] - current_date = self.date_from - for item in data: - current_label = self.get_period_label(self.aggregate_period, current_date) - while item['label'] != current_label: - result.append({'label': current_label, 'value': 0}) - current_date = self.get_next_period_date(self.aggregate_period, current_date) - current_label = self.get_period_label(self.aggregate_period, current_date) - if current_date > self.date_to: - break - if current_date > self.date_to: - break - result.append(item) - current_date = self.get_next_period_date(self.aggregate_period, current_date) - - while current_date <= self.date_to: - result.append({'label': self.get_period_label(self.aggregate_period, current_date), 'value': 0}) - current_date = self.get_next_period_date(self.aggregate_period, current_date) - - return result - - def _construct_result(self) -> dict: - """Construct the result dictionary""" - result: dict[Any, Any] = { - 'query_settings': { - 'aggregate_period': self.aggregate_period, - }, - 'by_tenant': [], - 'all_tenants': { - self.STAT_RESULT_KEYS[stat]: [] for stat in self.stats - }, - } - - all_tenants = result['all_tenants'] - all_tenants['totals'] = { - self.STAT_RESULT_KEYS[stat]: 0 for stat in self.stats - } - _by_period: dict[str, Any] = { - self.STAT_RESULT_KEYS[stat]: {} for stat in self.stats - } - for tenant_id in self.tenant_ids: - tenant_data: dict[str, Any] = { - 'tenant_id': tenant_id, - 'totals': {}, - } - for stat in self.stats: - key = self.STAT_RESULT_KEYS[stat] - data = self._get_stat_count(stat, tenant_id) - self.date_from = data[1] - self.date_to = data[2] - - if self.fill_missing_periods: - full_details = self.get_data_with_missing_periods(data[0], already_sorted=True) - else: - full_details = data[0] - tenant_data[key] = full_details - count = sum(item['value'] for item in full_details) - tenant_data['totals'][key] = count - - all_tenants['totals'][key] += count - for item in full_details: - _by_period[key][item['label']] = _by_period[key].get(item['label'], 0) + item['value'] - - result['by_tenant'].append(tenant_data) - - for stat in self.stats: - key = self.STAT_RESULT_KEYS[stat] - _by_period[key] = dict(sorted(_by_period[key].items())) - for item in _by_period[key]: - all_tenants[key].append({ - 'label': item, - 'value': _by_period[key][item], - }) - - result['limited_access'] = self.fx_permission_info['view_allowed_course_access_orgs'] != [] - result['query_settings']['date_from'] = self.date_from - result['query_settings']['date_to'] = self.date_to - - return result - - def get(self, request: Any, *args: Any, **kwargs: Any) -> Response: - """Returns the total count statistics for the selected tenants.""" - self._load_query_params(request) - - return Response(serializers.AggregatedCountsSerializer(self._construct_result()).data) - - -@docs('LearnersView.get') -class LearnersView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): - """View to get the list of learners""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - serializer_class = serializers.LearnerDetailsSerializer - pagination_class = DefaultPagination - fx_view_name = 'learners_list' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/learners/: Get the list of learners' - - def get_queryset(self) -> QuerySet: - """Get the list of learners""" - search_text = self.request.query_params.get('search_text') - include_staff = self.request.query_params.get('include_staff', '0') == '1' - min_enrollments_count = self.request.query_params.get('min_enrollments_count', -1) - max_enrollments_count = self.request.query_params.get('max_enrollments_count', -1) - - try: - min_enrollments_count = int(min_enrollments_count) - max_enrollments_count = int(max_enrollments_count) - except ValueError: - pass # let get_learners_queryset handle the invalid values - - return get_learners_queryset( - fx_permission_info=self.fx_permission_info, - search_text=search_text, - include_staff=include_staff, - enrollments_filter=(min_enrollments_count, max_enrollments_count), - ) - - -@docs('CoursesView.get') -@docs('CoursesView.post') -class CoursesView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): - """View to get the list of courses""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - serializer_class = serializers.CourseDetailsSerializer - pagination_class = DefaultPagination - filter_backends = [DefaultOrderingFilter] - ordering_fields = [ - 'id', 'self_paced', 'enrolled_count', 'active_count', - 'certificates_count', 'display_name', 'org', 'completion_rate', - ] - ordering = ['display_name'] - fx_view_name = 'courses_list' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/courses/v1/courses/: Get the list of courses' - - def get_queryset(self) -> QuerySet: - """Get the list of learners""" - search_text = self.request.query_params.get('search_text') - include_staff = self.request.query_params.get('include_staff') - - return get_courses_queryset( - fx_permission_info=self.fx_permission_info, - search_text=search_text, - visible_filter=None, - include_staff=include_staff, - ) - - def post(self, request: Any) -> Response | JsonResponse: # pylint: disable=no-self-use - """POST /api/fx/courses/v1/courses/""" - serializer = serializers.CourseCreateSerializer(data=request.data, context={'request': request}) - if serializer.is_valid(): - created_course = serializer.save() - return JsonResponse({ - 'id': str(created_course.id), - 'url': serializer.get_absolute_url(), - }) - - return Response( - {'errors': serializer.errors}, - status=http_status.HTTP_400_BAD_REQUEST - ) - - -@docs('LibraryView.get') -@docs('LibraryView.post') -class LibraryView(ExportCSVMixin, FXViewRoleInfoMixin, APIView): - """View to get the list of libraries""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - pagination_class = DefaultPagination - fx_view_name = 'libraries_list' - fx_default_read_only_roles = ['staff', 'instructor', 'library_user', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/libraries/v1/libraries/: Get the list of libraries' - - def get(self, request: Any) -> Response: - """ - GET /api/fx/libraries/v1/libraries/?tenant_ids= - - (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, - the API will assume the list of all accessible tenants by the user - """ - libraries = get_accessible_libraries(self.fx_permission_info, self.request.query_params.get('search_text')) - paginator = self.pagination_class() - page = paginator.paginate_queryset(libraries, request) - serializer = serializers.LibrarySerializer(page, many=True) - return paginator.get_paginated_response(serializer.data) - - def post(self, request: Any) -> Response: # pylint: disable=no-self-use - """ - POST /api/fx/libraries/v1/libraries/ - """ - serializer = serializers.LibrarySerializer(data=request.data, context={'request': request}) - if serializer.is_valid(): - created_library = serializer.save() - return JsonResponse( - {'library': str(created_library.location.library_key)}, - status=http_status.HTTP_201_CREATED, - ) - return Response( - {'errors': serializer.errors}, - status=http_status.HTTP_400_BAD_REQUEST - ) - - -@docs('CourseStatusesView.get') -class CourseStatusesView(FXViewRoleInfoMixin, APIView): - """View to get the course statuses""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'course_statuses' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/statistics/v1/course_statuses/: Get the course statuses' - - @staticmethod - def to_json(result: QuerySet) -> dict[str, int]: - """Convert the result to JSON format""" - dict_result = { - f'{COURSE_STATUS_SELF_PREFIX if self_paced else ""}{status}': 0 - for status in COURSE_STATUSES - for self_paced in [False, True] - } - - for item in result: - status = f'{COURSE_STATUS_SELF_PREFIX if item["self_paced"] else ""}{item["status"]}' - dict_result[status] = item['courses_count'] - return dict_result - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """ - GET /api/fx/statistics/v1/course_statuses/?tenant_ids= - - (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, - the API will assume the list of all accessible tenants by the user - """ - result = get_courses_count_by_status(fx_permission_info=self.fx_permission_info) - - return JsonResponse(self.to_json(result)) - - -@docs('LearnerInfoView.get') -class LearnerInfoView(FXViewRoleInfoMixin, APIView): - """View to get the information of a learner""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'learner_detailed_info' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/learner/: Get the information of a learner' - - def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonResponse | Response: - """ - GET /api/fx/learners/v1/learner// - """ - include_staff = request.query_params.get('include_staff', '0') == '1' - - try: - user = get_learner_info_queryset( - fx_permission_info=self.fx_permission_info, - user_key=username, - include_staff=include_staff, - ).first() - except FXCodedException as exc: - return_status = http_status.HTTP_404_NOT_FOUND if exc.code in ( - FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, FXExceptionCodes.USER_NOT_FOUND.value, - ) else http_status.HTTP_400_BAD_REQUEST - - return Response( - error_details_to_dictionary(reason=str(exc)), - status=return_status, - ) - - return JsonResponse( - serializers.LearnerDetailsExtendedSerializer(user, context={'request': request}).data - ) - - -@docs('DataExportManagementView.list') -@docs('DataExportManagementView.partial_update') -@docs('DataExportManagementView.retrieve') -class DataExportManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors - """View to list and retrieve data export tasks.""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - serializer_class = serializers.DataExportTaskSerializer - pagination_class = DefaultPagination - fx_view_name = 'exported_files_data' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_default_read_write_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_allowed_write_methods = ['PATCH'] - fx_view_description = 'api/fx/export/v1/tasks/: Data Export Task Management APIs.' - http_method_names = ['get', 'patch'] - filter_backends = [DjangoFilterBackend, DefaultOrderingFilter, DefaultSearchFilter] - filterset_fields = ['related_id', 'view_name'] - ordering = ['-id'] - search_fields = ['filename', 'notes'] - - def get_queryset(self) -> QuerySet: - """Get the list of user tasks.""" - return DataExportTask.objects.filter( - user=self.request.user, - tenant__id__in=self.fx_permission_info['view_allowed_tenant_ids_any_access'] - ) - - def get_object(self) -> DataExportTask: - """Override to ensure that the user can only retrieve their own tasks.""" - task_id = self.kwargs.get('pk') # Use 'pk' for the default lookup - task = get_object_or_404(DataExportTask, id=task_id, user=self.request.user) - return task - - -@docs('LearnerCoursesView.get') -class LearnerCoursesView(FXViewRoleInfoMixin, APIView): - """View to get the list of courses for a learner""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - pagination_class = DefaultPagination - fx_view_name = 'learner_courses' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/learner_courses/: Get the list of courses for a learner' - - def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonResponse | Response: - """ - GET /api/fx/learners/v1/learner_courses// - """ - include_staff = request.query_params.get('include_staff', '0') == '1' - - try: - courses = get_learner_courses_info_queryset( - fx_permission_info=self.fx_permission_info, - user_key=username, - visible_filter=None, - include_staff=include_staff, - ) - except FXCodedException as exc: - return_status = http_status.HTTP_404_NOT_FOUND if exc.code in ( - FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, FXExceptionCodes.USER_NOT_FOUND.value, - ) else http_status.HTTP_400_BAD_REQUEST - - return Response( - error_details_to_dictionary(reason=str(exc)), - status=return_status, - ) - - return Response(serializers.LearnerCoursesDetailsSerializer( - courses, context={'request': request}, many=True - ).data) - - -@docs('VersionInfoView.get') -class VersionInfoView(APIView): - """View to get the version information""" - permission_classes = [IsSystemStaff] - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use - """ - GET /api/fx/version/v1/info/ - """ - import futurex_openedx_extensions # pylint: disable=import-outside-toplevel - return JsonResponse({ - 'version': futurex_openedx_extensions.__version__, - }) - - -@docs('AccessibleTenantsInfoView.get') -class AccessibleTenantsInfoView(APIView): - """View to get the list of accessible tenants""" - permission_classes = [IsAnonymousOrSystemStaff] - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use - """ - GET /api/fx/accessible/v1/info/?username_or_email= - """ - username_or_email = request.query_params.get('username_or_email') - try: - user = get_user_by_username_or_email(username_or_email) - except ObjectDoesNotExist: - user = None - - if not user: - return JsonResponse({}) - - tenant_ids = get_accessible_tenant_ids(user) - return JsonResponse(get_tenants_info(tenant_ids)) - - -@docs('AccessibleTenantsInfoViewV2.get') -class AccessibleTenantsInfoViewV2(FXViewRoleInfoMixin, APIView): - """View to get the list of accessible tenants version 2""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'accessible_info' - fx_view_description = 'api/fx/accessible/v2/info/: Get accessible tenants' - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use - """ - GET /api/fx/accessible/v1/info/?username_or_email= - """ - username_or_email = request.query_params.get('username_or_email') - try: - user = get_user_by_username_or_email(username_or_email) - except ObjectDoesNotExist: - user = None - - if not user: - return JsonResponse({}) - - tenant_ids = get_accessible_tenant_ids(user) - return JsonResponse(get_tenants_info(tenant_ids)) - - -@docs('LearnersDetailsForCourseView.get') -class LearnersDetailsForCourseView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): - """View to get the list of learners for a course""" - authentication_classes = default_auth_classes - serializer_class = serializers.LearnerDetailsForCourseSerializer - permission_classes = [FXHasTenantCourseAccess] - pagination_class = DefaultPagination - fx_view_name = 'learners_with_details_for_course' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/learners/: Get the list of learners for a course' - - def get_related_id(self) -> None: - """ - Related ID is course_id for this view. - """ - return self.kwargs.get('course_id') - - def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: - """Get the list of learners for a course""" - search_text = self.request.query_params.get('search_text') - course_id = self.kwargs.get('course_id') - include_staff = self.request.query_params.get('include_staff', '0') == '1' - - return get_learners_by_course_queryset( - course_id=course_id, - search_text=search_text, - include_staff=include_staff, - ) - - def get_serializer_context(self) -> Dict[str, Any]: - """Get the serializer context""" - context = super().get_serializer_context() - context['course_id'] = self.kwargs.get('course_id') - context['omit_subsection_name'] = self.request.query_params.get('omit_subsection_name', '0') - return context - - -@docs('CoursesFeedbackView.get') -class CoursesFeedbackView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): - """View to get the list of courses feedbacks""" - authentication_classes = default_auth_classes - serializer_class = serializers.CoursesFeedbackSerializer - permission_classes = [FXHasTenantCourseAccess] - pagination_class = DefaultPagination - fx_view_name = 'courses_feedback' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/courses/v1/feedback: Get the list of feedbacks' - - def validate_rating_list(self, param_key: str) -> list[int] | None: - """ - Validates that the input string from query parameters is a comma-separated list - of integers between 1 and 5. Returns the parsed list if valid. - - :param param_key: The key in query params to validate (e.g. 'rating_content') - :return: List of integers if valid, or None if not provided - :raises: FXCodedException if validation fails - """ - value = self.request.query_params.get(param_key) - if not value: - return None - - try: - ratings = [int(r.strip()) for r in value.split(',')] - except ValueError as exc: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=f"'{param_key}' must be a comma-separated list of valid integers." - ) from exc - - if any(r < 0 or r > 5 for r in ratings): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=f"Each value in '{param_key}' must be between 0 and 5 (inclusive)." - ) - - return ratings - - def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: - """Get the list of course feedbacks""" - course_ids = self.request.query_params.get('course_ids', '') - course_ids_list = [ - course.strip() for course in course_ids.split(',') - ] if course_ids else None - - return get_courses_feedback_queryset( - fx_permission_info=self.request.fx_permission_info, - course_ids=course_ids_list, - public_only=self.request.query_params.get('public_only', '0') == '1', - recommended_only=self.request.query_params.get('recommended_only', '0') == '1', - feedback_search=self.request.query_params.get('feedback_search'), - rating_content_filter=self.validate_rating_list('rating_content'), - rating_instructors_filter=self.validate_rating_list('rating_instructors') - ) - - -@docs('LearnersEnrollmentView.get') -class LearnersEnrollmentView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): - """View to get the list of learners for a course""" - authentication_classes = default_auth_classes - serializer_class = serializers.LearnerEnrollmentSerializer - permission_classes = [FXHasTenantCourseAccess] - pagination_class = DefaultPagination - fx_view_name = 'learners_enrollment_details' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/enrollments: Get the list of enrollments' - is_single_course_requested = False - - def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: - """Get the list of learners for a course""" - course_ids = self.request.query_params.get('course_ids', '') - user_ids = self.request.query_params.get('user_ids', '') - usernames = self.request.query_params.get('usernames', '') - course_ids_list = [ - course.strip() for course in course_ids.split(',') - ] if course_ids else None - user_ids_list = [ - int(user.strip()) for user in user_ids.split(',') if user.strip().isdigit() - ] if user_ids else None - usernames_list = [ - username.strip() for username in usernames.split(',') - ] if usernames else None - - if course_ids_list and len(course_ids_list) == 1: - self.is_single_course_requested = True - - return get_learners_enrollments_queryset( - fx_permission_info=self.request.fx_permission_info, - user_ids=user_ids_list, - course_ids=course_ids_list, - usernames=usernames_list, - learner_search=self.request.query_params.get('learner_search'), - course_search=self.request.query_params.get('course_search'), - include_staff=self.request.query_params.get('include_staff', '0') == '1', - ) - - def get_serializer_context(self) -> Dict[str, Any]: - """Get the serializer context""" - context = super().get_serializer_context() - if self.is_single_course_requested and self.get_queryset().exists(): - context['course_id'] = str(self.get_queryset().first().course_id) - context['omit_subsection_name'] = self.request.query_params.get('omit_subsection_name', '0') - return context - - -@docs('GlobalRatingView.get') -class GlobalRatingView(FXViewRoleInfoMixin, APIView): - """View to get the global rating""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'global_rating' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/statistics/v1/rating/: Get the global rating for courses' - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """ - GET /api/fx/statistics/v1/rating/?tenant_ids= - - (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, - the API will assume the list of all accessible tenants by the user - """ - data_result = get_courses_ratings(fx_permission_info=self.fx_permission_info) - result = { - 'total_rating': data_result['total_rating'], - 'total_count': sum(data_result[f'rating_{index}_count'] for index in range(1, 6)), - 'courses_count': data_result['courses_count'], - 'rating_counts': { - str(index): data_result[f'rating_{index}_count'] for index in range(1, 6) - }, - } - - return JsonResponse(result) - - -@docs('UserRolesManagementView.create') -@docs('UserRolesManagementView.destroy') -@docs('UserRolesManagementView.list') -@docs('UserRolesManagementView.retrieve') -@docs('UserRolesManagementView.update') -@exclude_schema_for('partial_update') -class UserRolesManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors - """View to get the user roles""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'user_roles' - fx_default_read_only_roles = ['org_course_creator_group'] - fx_default_read_write_roles = ['org_course_creator_group'] - fx_allowed_write_methods = ['POST', 'PUT', 'DELETE'] - fx_view_description = 'api/fx/roles/v1/user_roles/: user roles management APIs' - - lookup_field = 'username' - lookup_value_regex = '[^/]+' - serializer_class = serializers.UserRolesSerializer - pagination_class = DefaultPagination - - @transaction.non_atomic_requests - def dispatch(self, *args: Any, **kwargs: Any) -> Response: - return super().dispatch(*args, **kwargs) - - def get_queryset(self) -> QuerySet: - """Get the list of users""" - dummy_serializers = serializers.UserRolesSerializer(context={'request': self.request}) - - try: - q_set = get_user_model().objects.filter( - id__in=get_course_access_roles_queryset( - orgs_filter=dummy_serializers.orgs_filter, - remove_redundant=True, - users=None, - search_text=dummy_serializers.query_params['search_text'], - roles_filter=dummy_serializers.query_params['roles_filter'], - active_filter=dummy_serializers.query_params['active_filter'], - course_ids_filter=dummy_serializers.query_params['course_ids_filter'], - excluded_role_types=dummy_serializers.query_params['excluded_role_types'], - excluded_hidden_roles=not dummy_serializers.query_params['include_hidden_roles'], - ).values('user_id').distinct().order_by() - ).select_related('profile').order_by('id') - except (ValueError, FXCodedException) as exc: - raise ParseError(f'Invalid parameter: {exc}') from exc - - return q_set - - def create(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse: - """Create a new user role""" - data = request.data - try: - if ( - not isinstance(data['tenant_ids'], list) or - not all(isinstance(t_id, int) for t_id in data['tenant_ids']) - ): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='tenant_ids must be a list of integers', - ) - - if not isinstance(data['users'], list): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='users must be a list', - ) - - if not isinstance(data['role'], str): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='role must be a string', - ) - - if not isinstance(data['tenant_wide'], int): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='tenant_wide must be an integer flag', - ) - - if not isinstance(data.get('course_ids', []), list): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='course_ids must be a list', - ) - - result = add_course_access_roles( - caller=self.fx_permission_info['user'], - tenant_ids=data['tenant_ids'], - user_keys=data['users'], - role=data['role'], - tenant_wide=data['tenant_wide'] != 0, - course_ids=data.get('course_ids', []), - ) - 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 - ) - - return JsonResponse( - result, - status=http_status.HTTP_201_CREATED, - ) - - @staticmethod - def verify_username(username: str) -> Response | Dict[str, Any]: - """Verify the username""" - user_info = get_user_by_key(username) - if not user_info['user']: - return Response( - error_details_to_dictionary(reason=f'({user_info["error_code"]}) {user_info["error_message"]}'), - status=http_status.HTTP_404_NOT_FOUND - ) - return user_info - - def update(self, request: Any, *args: Any, **kwargs: Any) -> Response: - """Update a user role""" - user_info = self.verify_username(kwargs['username']) - if isinstance(user_info, Response): - return user_info - - result = update_course_access_roles( - caller=self.fx_permission_info['user'], - user=user_info['user'], - new_roles_details=request.data or {}, - dry_run=False, - ) - - if result['error_code']: - return Response( - error_details_to_dictionary(reason=f'({result["error_code"]}) {result["error_message"]}'), - status=http_status.HTTP_400_BAD_REQUEST - ) - - return Response( - self.serializer_class(user_info['user'], context={'request': request}).data, - status=http_status.HTTP_200_OK, - ) - - def destroy(self, request: Any, *args: Any, **kwargs: Any) -> Response: - """Delete a user role""" - if not request.query_params.get('tenant_ids'): - return Response( - error_details_to_dictionary(reason="Missing required parameter: 'tenant_ids'"), - status=http_status.HTTP_400_BAD_REQUEST - ) - - user_info = self.verify_username(kwargs['username']) - if isinstance(user_info, Response): - return user_info - - try: - delete_course_access_roles( - caller=self.fx_permission_info['user'], - tenant_ids=self.fx_permission_info['view_allowed_tenant_ids_any_access'], - user=user_info['user'], - ) - except FXCodedException as exc: - return Response( - error_details_to_dictionary(reason=str(exc)), - status=http_status.HTTP_404_NOT_FOUND - ) - - return Response(status=http_status.HTTP_204_NO_CONTENT) - - -@docs('MyRolesView.get') -class MyRolesView(FXViewRoleInfoMixin, APIView): - """View to get the user roles of the caller""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'my_roles' - fx_default_read_only_roles = COURSE_ACCESS_ROLES_SUPPORTED_READ.copy() - fx_view_description = 'api/fx/roles/v1/my_roles/: user roles management APIs' - - serializer_class = serializers.UserRolesSerializer - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """Get the list of users""" - data = serializers.UserRolesSerializer(self.fx_permission_info['user'], context={'request': request}).data - data['is_system_staff'] = self.fx_permission_info['is_system_staff_user'] - return JsonResponse(data) - - -@docs('ExcludedTenantsView.get') -class ExcludedTenantsView(APIView): - """View to get the list of excluded tenants""" - authentication_classes = default_auth_classes - permission_classes = [IsSystemStaff] - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use - """Get the list of excluded tenants""" - return JsonResponse(get_excluded_tenant_ids()) - - -@docs('TenantInfoView.get') -class TenantInfoView(FXViewRoleInfoMixin, APIView): - """View to get the list of excluded tenants""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'tenant_info' - fx_default_read_only_roles = COURSE_ACCESS_ROLES_SUPPORTED_READ.copy() - fx_view_description = 'api/fx/tenants/v1/info//: tenant basic information' - - def get( - self, request: Any, tenant_id: str, *args: Any, **kwargs: Any, - ) -> JsonResponse | Response: - """Get the tenant's information by tenant ID""" - if int(tenant_id) not in self.request.fx_permission_info['view_allowed_tenant_ids_any_access']: - return Response( - error_details_to_dictionary(reason='You do not have access to this tenant'), - status=http_status.HTTP_403_FORBIDDEN, - ) - - result = {'tenant_id': int(tenant_id)} - result.update(get_all_tenants_info()['info'].get(int(tenant_id))) - return JsonResponse(result) - - -@exclude_schema_for('get') -class ClickhouseQueryView(FXViewRoleInfoMixin, APIView): - """View to get the Clickhouse query""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantCourseAccess] - fx_view_name = 'clickhouse_query_fetcher' - fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/query/v1//: Get result of the related clickhouse query' - - @staticmethod - def get_page_url_with_page(url: str, new_page_no: int | None) -> str | None: - """ - Get the URL with the new page number - - :param url: The URL - :type url: str - :param new_page_no: The new page number - :type new_page_no: int | None - :return: The URL with the new page number - :rtype: str | None - """ - if new_page_no is None: - return None - - url_parts = urlsplit(url) - query_params = parse_qs(url_parts.query) - - page_size = query_params.get(DefaultPagination.page_size_query_param, None) - if page_size: - del query_params[DefaultPagination.page_size_query_param] - - if 'page' in query_params: - del query_params['page'] - - if page_size: - query_params[DefaultPagination.page_size_query_param] = page_size - query_params['page'] = [str(new_page_no)] - - new_query_string = urlencode(query_params, doseq=True) - - new_url_parts = (url_parts.scheme, url_parts.netloc, url_parts.path, new_query_string, url_parts.fragment) - new_full_url = urlunsplit(new_url_parts) - return new_full_url - - @staticmethod - def pop_out_page_params(params: Dict[str, str], paginated: bool) -> tuple[int | None, int]: - """ - Pop out the page and page size parameters, and return them as integers in the result. Always return the page - as None if not paginated - - :param params: The parameters - :type params: Dict[str, str] - :param paginated: Whether the query is paginated - :type paginated: bool - :return: The page and page size parameters - :rtype: tuple[int | None, int] - """ - page_str: str | None = params.pop('page', None) - page_size_str: str = params.pop( - DefaultPagination.page_size_query_param, '' - ) or str(DefaultPagination.page_size) - - if not paginated: - page = None - else: - page = int(page_str) if page_str is not None else page_str - page = 1 if page is None else page - - return page, int(page_size_str) - - def get(self, request: Any, scope: str, slug: str) -> JsonResponse | Response: - """ - GET /api/fx/query/v1/// - - :param request: The request object - :type request: Request - :param scope: The scope of the query (course, tenant, user) - :type scope: str - :param slug: The slug of the query - :type slug: str - """ - clickhouse_query = ClickhouseQuery.get_query_record(scope, 'v1', slug) - if not clickhouse_query: - return Response( - error_details_to_dictionary(reason=f'Query not found {scope}.v1.{slug}'), - status=http_status.HTTP_404_NOT_FOUND - ) - - if not clickhouse_query.enabled: - return Response( - error_details_to_dictionary(reason=f'Query is disabled {scope}.v1.{slug}'), - status=http_status.HTTP_400_BAD_REQUEST - ) - - params = request.query_params.dict() - self.get_page_url_with_page(request.build_absolute_uri(), 9) - - page, page_size = self.pop_out_page_params(params, clickhouse_query.paginated) - - orgs = request.fx_permission_info['view_allowed_any_access_orgs'].copy() - params[CLICKHOUSE_FX_BUILTIN_ORG_IN_TENANTS] = orgs - if CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS in clickhouse_query.query: - params[CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS] = get_usernames_with_access_roles(orgs) - - error_response = None - try: - clickhouse_query.fix_param_types(params) - - with ch.get_client() as clickhouse_client: - records_count, next_page, result = ch.execute_query( - clickhouse_client, - query=clickhouse_query.query, - parameters=params, - page=page, - page_size=page_size, - ) - - except EmptyPage as exc: - error_response = Response( - error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_404_NOT_FOUND - ) - except (ch.ClickhouseClientNotConfiguredError, ch.ClickhouseClientConnectionError) as exc: - error_response = Response( - error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_503_SERVICE_UNAVAILABLE - ) - except (ch.ClickhouseBaseError, ValueError) as exc: - error_response = Response( - error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_400_BAD_REQUEST - ) - except ValidationError as exc: - error_response = Response( - error_details_to_dictionary(reason=exc.message), status=http_status.HTTP_400_BAD_REQUEST - ) - - if error_response: - return error_response - - if clickhouse_query.paginated: - return JsonResponse({ - 'count': records_count, - 'next': self.get_page_url_with_page(request.build_absolute_uri(), next_page), - 'previous': self.get_page_url_with_page( - request.build_absolute_uri(), - None if page == 1 else page - 1 if page else None, - ), - 'results': ch.result_to_json(result), - }) - - return JsonResponse(ch.result_to_json(result), safe=False) - - -@docs('ConfigEditableInfoView.get') -class ConfigEditableInfoView(FXViewRoleInfoMixin, APIView): - """View to get the list of editable keys of the theme designer config""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'fx_config_editable_fields' - fx_view_description = 'api/fx/config/v1/editable: Get editable settings of config' - fx_default_read_write_roles = ['staff', 'fx_api_access_global'] - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - - def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """ - GET /api/fx/config/v1/editable/ - """ - tenant_id = self.verify_one_tenant_id_provided(request) - - return JsonResponse({ - 'editable_fields': get_accessible_config_keys( - user_id=request.user.id, - tenant_id=tenant_id, - writable_fields_filter=True, - ), - 'read_only_fields': get_accessible_config_keys( - user_id=request.user.id, - tenant_id=tenant_id, - writable_fields_filter=False, - ), - }) - - -@docs('ThemeConfigDraftView.get') -@docs('ThemeConfigDraftView.put') -@docs('ThemeConfigDraftView.delete') -class ThemeConfigDraftView(FXViewRoleInfoMixin, APIView): - """View to manage draft theme config""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - 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_default_read_write_roles = ['staff', 'fx_api_access_global'] - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - fx_tenant_id_url_arg_name: str = 'tenant_id' - - def get(self, request: Any, tenant_id: int) -> Response | JsonResponse: # pylint: disable=no-self-use - """Get draft config""" - updated_fields = get_draft_tenant_config(tenant_id=int(tenant_id)) - return JsonResponse({ - 'updated_fields': updated_fields, - 'draft_hash': dict_to_hash(updated_fields) - }) - - @staticmethod - def validate_input(current_revision_id: int) -> None: - """Validate the input""" - if current_revision_id is None: - raise KeyError('current_revision_id') - - try: - _ = int(current_revision_id) - except ValueError as exc: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='current_revision_id type must be numeric value.' - ) from exc - - def put(self, request: Any, tenant_id: int) -> Response: - """Update draft config""" - data = request.data - try: - key = data['key'] - if not isinstance(key, str): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, message='Key name must be a string.' - ) - - key_access_info = ConfigAccessControl.objects.get(key_name=key) - if not key_access_info.writable: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, message=f'Config Key: ({data["key"]}) is not writable.' - ) - - if 'reset' not in data and 'new_value' not in data: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, message='Provide either new_value or reset.' - ) - - new_value = data.get('new_value') - current_revision_id = data.get('current_revision_id') - reset = data.get('reset', False) is True - self.validate_input(current_revision_id) - - update_draft_tenant_config( - tenant_id=int(tenant_id), - config_path=key_access_info.path, - current_revision_id=int(current_revision_id), - new_value=new_value, - reset=reset, - user=request.user, - ) - - data = get_tenant_config(tenant_id=int(tenant_id), keys=[key], published_only=False) - return Response( - status=http_status.HTTP_200_OK, - data=serializers.TenantConfigSerializer(data, context={'request': request}).data, - ) - - 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: - if exc.code in [ - FXExceptionCodes.DRAFT_CONFIG_CREATE_MISMATCH.value, - FXExceptionCodes.DRAFT_CONFIG_UPDATE_MISMATCH.value, - FXExceptionCodes.DRAFT_CONFIG_DELETE_MISMATCH.value, - ]: - return Response( - error_details_to_dictionary(reason=f'({exc.code}) {str(exc)}'), - status=http_status.HTTP_409_CONFLICT - ) - return Response( - error_details_to_dictionary(reason=f'({exc.code}) {str(exc)}'), - status=http_status.HTTP_400_BAD_REQUEST - ) - except ConfigAccessControl.DoesNotExist: - return Response( - error_details_to_dictionary( - reason=f'Invalid 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: int) -> Response: # pylint: disable=no-self-use - """Delete draft config""" - delete_draft_tenant_config(tenant_id=int(tenant_id)) - return Response(status=http_status.HTTP_204_NO_CONTENT) - - -@docs('ThemeConfigPublishView.post') -class ThemeConfigPublishView(FXViewRoleInfoMixin, APIView): - """View to publish theme config""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'theme_config_publish' - fx_view_description = 'api/fx/config/v1/publish/: Get editable settings of config' - fx_default_read_write_roles = ['staff', 'fx_api_access_global'] - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - - @staticmethod - def validate_payload(data: dict, fx_permission_info: dict) -> dict: - """ - Validates the payload. - - :param data: The payload data from the request - :param fx_permission_info: The permission info - :raises FXCodedException: If the payload data is invalid - """ - tenant_id = data.get('tenant_id') - if not tenant_id or not isinstance(tenant_id, int): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Tenant id is required and must be an int.' - ) - - if tenant_id not in fx_permission_info['view_allowed_tenant_ids_full_access']: - raise PermissionDenied(detail=json.dumps( - {'reason': f'User does not have required access for tenant ({tenant_id})'} - )) - - draft_hash = data.get('draft_hash') - if not draft_hash or not isinstance(draft_hash, str): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Draft hash is required and must be a string.' - ) - current_draft = get_draft_tenant_config(tenant_id=tenant_id) - current_draft_hash = dict_to_hash(current_draft) - if current_draft_hash != draft_hash: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Draft hash mismatched with current draft values hash.' - ) - return current_draft - - @staticmethod - def rename_keys(updated_fields: dict) -> dict: - """ - Rename 'published_value' to 'old_value' and 'draft_value' to 'new_value - """ - renamed_data = {} - for key, value in updated_fields.items(): - renamed_data[key] = { - 'old_value': value.get('published_value', None), - 'new_value': value.get('draft_value', None) - } - return renamed_data - - def post(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """ - POST /api/fx/config/v1/publish/ - """ - data = request.data - updated_fields = self.validate_payload(data, self.request.fx_permission_info) - publish_tenant_config(data['tenant_id']) - return JsonResponse({'updated_fields': self.rename_keys(updated_fields)}) - - -@docs('ThemeConfigRetrieveView.get') -class ThemeConfigRetrieveView(FXViewRoleInfoMixin, APIView): - """View to get theme config values""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'theme_config_values' - fx_view_description = 'api/fx/config/v1/values/: Get theme config values' - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - - def validate_keys(self, tenant_id: int) -> list: - """Validate keys""" - keys = self.request.query_params.get('keys', '') - if keys: - return keys.split(',') - - return get_accessible_config_keys(user_id=self.request.user.id, tenant_id=tenant_id) - - def get(self, request: Any, *args: Any, **kwargs: Any) -> Response: - """ - GET /api/fx/config/v1/values/ - """ - tenant_id = self.verify_one_tenant_id_provided(request) - - data = get_tenant_config( - tenant_id, - self.validate_keys(tenant_id=tenant_id), - request.query_params.get('published_only', '0') == '1' - ) - return Response(serializers.TenantConfigSerializer(data, context={'request': request}).data) - - -@docs('ThemeConfigTenantView.post') -class ThemeConfigTenantView(FXViewRoleInfoMixin, APIView): - """View to create new Tenant and theme config""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'theme_config_tenant' - fx_view_description = 'api/fx/config/v1/tenant/: Create new Tenant' - - @staticmethod - def validate_payload(data: dict) -> None: - """ - Validates the payload. - - :param data: The payload data from the request - :raises FXCodedException: If the payload data is invalid - """ - sub_domain = data.get('sub_domain') - if not sub_domain: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Subdomain is required.' - ) - if not isinstance(sub_domain, str): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Subdomain must be a string.' - ) - if len(sub_domain) > 16: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Subdomain cannot exceed 16 characters.' - ) - if not re.match(r'^[a-zA-Z][a-zA-Z0-9]*$', sub_domain): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=( - 'Subdomain can only contain letters and numbers and cannot start with a number.' - ) - ) - - platform_name = data.get('platform_name') - if not platform_name: - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Platform name is required.' - ) - if not isinstance(platform_name, str): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message='Platform name must be a string.' - ) - - owner_user_id = data.get('owner_user_id') - if owner_user_id and not get_user_model().objects.filter(id=owner_user_id).exists(): - raise FXCodedException( - code=FXExceptionCodes.INVALID_INPUT, - message=f'User with ID {owner_user_id} does not exist.' - ) - - def post(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: - """ - POST /api/fx/config/v1/tenant/ - """ - data = request.data - self.validate_payload(data) - tenant_config = create_new_tenant_config(data['sub_domain'], data['platform_name']) - owner_user_id = data.get('owner_user_id') - if owner_user_id: - add_course_access_roles( - caller=self.fx_permission_info['user'], - tenant_ids=[tenant_config.id], - user_keys=[data['owner_user_id']], - role=COURSE_ACCESS_ROLES_STAFF_EDITOR, - tenant_wide=True, - course_ids=[], - ) - - result = {'tenant_id': tenant_config.id} - result.update(get_all_tenants_info()['info'].get(tenant_config.id)) - return JsonResponse(result) - - -class FileUploadView(FXViewRoleInfoMixin, APIView): - """View to upload file""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - fx_view_name = 'upload_file' - fx_view_description = 'api/fx/file/v1/upload/: Upload file' - fx_default_read_write_roles = ['staff', 'fx_api_access_global'] - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - - parser_classes = [MultiPartParser] - - @swagger_auto_schema( - request_body=serializers.FileUploadSerializer, - ) - def post(self, request: Any, *args: Any, **kwargs: Any) -> Response: - """ - POST /api/fx/file/v1/upload/ - - Validates the payload, saves the file, and returns the file URL. - """ - serializer = serializers.FileUploadSerializer(data=request.data, context={'request': self.request}) - - if not serializer.is_valid(): - return Response(serializer.errors, status=http_status.HTTP_400_BAD_REQUEST) - - file = serializer.validated_data['file'] - slug = serializer.validated_data['slug'] - tenant_id = serializer.validated_data['tenant_id'] - - file_extension = os.path.splitext(file.name)[1] - if file_extension.lower() not in ALLOWED_FILE_EXTENSIONS: - return Response( - error_details_to_dictionary( - reason=f'Invalid file type. Allowed types are {ALLOWED_FILE_EXTENSIONS}.' - ), - status=http_status.HTTP_400_BAD_REQUEST - ) - short_uuid = uuid.uuid4().hex[:8] - file_name = f'{slug}-{short_uuid}{file_extension}' - storage_path = os.path.join(get_storage_dir(tenant_id, CONFIG_FILES_UPLOAD_DIR), file_name) - return Response( - {'url': upload_file(storage_path, file), 'uuid': short_uuid}, - status=http_status.HTTP_201_CREATED - ) - - -@docs('TenantAssetsManagementView.create') -@docs('TenantAssetsManagementView.list') -@exclude_schema_for('retrieve', 'update', 'partial_update', 'destroy') -class TenantAssetsManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors - """View to list and retrieve course assets.""" - authentication_classes = default_auth_classes - permission_classes = [FXHasTenantAllCoursesAccess] - serializer_class = serializers.TenantAssetSerializer - pagination_class = DefaultPagination - fx_view_name = 'tenant_assets' - fx_default_read_write_roles = ['staff', 'fx_api_access_global'] - fx_default_read_only_roles = ['staff', 'fx_api_access_global'] - fx_allowed_write_methods = ['POST'] - fx_view_description = 'api/fx/tenant/v1/assets/: Tenant Assets Management APIs.' - filter_backends = [DefaultOrderingFilter, DjangoFilterBackend, DefaultSearchFilter] - filterset_fields = ['tenant_id', 'updated_by'] - ordering = ['-id'] - search_fields = ['slug'] - - parser_classes = [MultiPartParser] - - def get_queryset(self) -> QuerySet: - """Get the list of user uploaded files.""" - is_staff_user = self.request.fx_permission_info['is_system_staff_user'] - accessible_tenant_ids = self.request.fx_permission_info['view_allowed_tenant_ids_full_access'] - if is_staff_user: - template_tenant_id = get_all_tenants_info()['template_tenant']['tenant_id'] - if template_tenant_id: - accessible_tenant_ids.append(template_tenant_id) - - result = TenantAsset.objects.filter(tenant__id__in=accessible_tenant_ids) - if not is_staff_user: - result = result.exclude(slug__startswith='_') - - return result - - -class SetThemePreviewCookieView(APIView): - """View to set theme preview cookie""" - def get(self, request: Any) -> Any: # pylint: disable=no-self-use - """Set theme preview cookie""" - next_url = request.GET.get('next', request.build_absolute_uri()) - if request.COOKIES.get('theme-preview') == 'yes': - return redirect(next_url) - - return render(request, template_name='set_theme_preview.html', context={'next_url': next_url}) diff --git a/futurex_openedx_extensions/dashboard/views/__init__.py b/futurex_openedx_extensions/dashboard/views/__init__.py new file mode 100644 index 00000000..b6769bbd --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/__init__.py @@ -0,0 +1,74 @@ +"""Views for the dashboard app""" +from futurex_openedx_extensions.dashboard.views.assets import FileUploadView, TenantAssetsManagementView +from futurex_openedx_extensions.dashboard.views.config import ( + ConfigEditableInfoView, + ThemeConfigDraftView, + ThemeConfigPublishView, + ThemeConfigRetrieveView, + ThemeConfigTenantView, +) +from futurex_openedx_extensions.dashboard.views.courses import ( + CoursesFeedbackView, + CoursesView, + CourseStatusesView, + GlobalRatingView, + LibraryView, +) +from futurex_openedx_extensions.dashboard.views.learners import ( + LearnerCoursesView, + LearnerInfoView, + LearnersDetailsForCourseView, + LearnersEnrollmentView, + LearnersView, +) +from futurex_openedx_extensions.dashboard.views.roles import MyRolesView, UserRolesManagementView +from futurex_openedx_extensions.dashboard.views.statistics import AggregatedCountsView, TotalCountsView +from futurex_openedx_extensions.dashboard.views.system import ( + AccessibleTenantsInfoView, + AccessibleTenantsInfoViewV2, + ClickhouseQueryView, + DataExportManagementView, + ExcludedTenantsView, + SetThemePreviewCookieView, + TenantInfoView, + VersionInfoView, +) + +__all__ = [ + # Assets + 'FileUploadView', + 'TenantAssetsManagementView', + # Config + 'ConfigEditableInfoView', + 'ThemeConfigDraftView', + 'ThemeConfigPublishView', + 'ThemeConfigRetrieveView', + 'ThemeConfigTenantView', + # Courses + 'CoursesFeedbackView', + 'CoursesView', + 'CourseStatusesView', + 'GlobalRatingView', + 'LibraryView', + # Learners + 'LearnerCoursesView', + 'LearnerInfoView', + 'LearnersDetailsForCourseView', + 'LearnersEnrollmentView', + 'LearnersView', + # Roles + 'MyRolesView', + 'UserRolesManagementView', + # Statistics + 'AggregatedCountsView', + 'TotalCountsView', + # System + 'AccessibleTenantsInfoView', + 'AccessibleTenantsInfoViewV2', + 'ClickhouseQueryView', + 'DataExportManagementView', + 'ExcludedTenantsView', + 'SetThemePreviewCookieView', + 'TenantInfoView', + 'VersionInfoView', +] diff --git a/futurex_openedx_extensions/dashboard/views/assets.py b/futurex_openedx_extensions/dashboard/views/assets.py new file mode 100644 index 00000000..12ae4ac4 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/assets.py @@ -0,0 +1,117 @@ +"""Assets views for the dashboard app""" +from __future__ import annotations + +import os +import uuid +from typing import Any + +from django.db.models.query import QuerySet +from django_filters.rest_framework import DjangoFilterBackend +from drf_yasg.utils import swagger_auto_schema +from edx_api_doc_tools import exclude_schema_for +from rest_framework import status as http_status +from rest_framework import viewsets +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.helpers.constants import ( + ALLOWED_FILE_EXTENSIONS, + CONFIG_FILES_UPLOAD_DIR, + FX_VIEW_DEFAULT_AUTH_CLASSES, +) +from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter, DefaultSearchFilter +from futurex_openedx_extensions.helpers.models import TenantAsset +from futurex_openedx_extensions.helpers.pagination import DefaultPagination +from futurex_openedx_extensions.helpers.permissions import FXHasTenantAllCoursesAccess +from futurex_openedx_extensions.helpers.roles import FXViewRoleInfoMixin +from futurex_openedx_extensions.helpers.tenants import get_all_tenants_info +from futurex_openedx_extensions.helpers.upload import get_storage_dir, upload_file + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +class FileUploadView(FXViewRoleInfoMixin, APIView): + """View to upload file""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'upload_file' + fx_view_description = 'api/fx/file/v1/upload/: Upload file' + fx_default_read_write_roles = ['staff', 'fx_api_access_global'] + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + + parser_classes = [MultiPartParser] + + @swagger_auto_schema( + request_body=serializers.FileUploadSerializer, + ) + def post(self, request: Any, *args: Any, **kwargs: Any) -> Response: + """ + POST /api/fx/file/v1/upload/ + + Validates the payload, saves the file, and returns the file URL. + """ + serializer = serializers.FileUploadSerializer(data=request.data, context={'request': self.request}) + + if not serializer.is_valid(): + return Response(serializer.errors, status=http_status.HTTP_400_BAD_REQUEST) + + file = serializer.validated_data['file'] + slug = serializer.validated_data['slug'] + tenant_id = serializer.validated_data['tenant_id'] + + file_extension = os.path.splitext(file.name)[1] + if file_extension.lower() not in ALLOWED_FILE_EXTENSIONS: + return Response( + error_details_to_dictionary( + reason=f'Invalid file type. Allowed types are {ALLOWED_FILE_EXTENSIONS}.' + ), + status=http_status.HTTP_400_BAD_REQUEST + ) + short_uuid = uuid.uuid4().hex[:8] + file_name = f'{slug}-{short_uuid}{file_extension}' + storage_path = os.path.join(get_storage_dir(tenant_id, CONFIG_FILES_UPLOAD_DIR), file_name) + return Response( + {'url': upload_file(storage_path, file), 'uuid': short_uuid}, + status=http_status.HTTP_201_CREATED + ) + + +@docs('TenantAssetsManagementView.create') +@docs('TenantAssetsManagementView.list') +@exclude_schema_for('retrieve', 'update', 'partial_update', 'destroy') +class TenantAssetsManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors + """View to list and retrieve course assets.""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + serializer_class = serializers.TenantAssetSerializer + pagination_class = DefaultPagination + fx_view_name = 'tenant_assets' + fx_default_read_write_roles = ['staff', 'fx_api_access_global'] + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + fx_allowed_write_methods = ['POST'] + fx_view_description = 'api/fx/tenant/v1/assets/: Tenant Assets Management APIs.' + filter_backends = [DefaultOrderingFilter, DjangoFilterBackend, DefaultSearchFilter] + filterset_fields = ['tenant_id', 'updated_by'] + ordering = ['-id'] + search_fields = ['slug'] + + parser_classes = [MultiPartParser] + + def get_queryset(self) -> QuerySet: + """Get the list of user uploaded files.""" + is_staff_user = self.request.fx_permission_info['is_system_staff_user'] + accessible_tenant_ids = self.request.fx_permission_info['view_allowed_tenant_ids_full_access'] + if is_staff_user: + template_tenant_id = get_all_tenants_info()['template_tenant']['tenant_id'] + if template_tenant_id: + accessible_tenant_ids.append(template_tenant_id) + + result = TenantAsset.objects.filter(tenant__id__in=accessible_tenant_ids) + if not is_staff_user: + result = result.exclude(slug__startswith='_') + + return result diff --git a/futurex_openedx_extensions/dashboard/views/config.py b/futurex_openedx_extensions/dashboard/views/config.py new file mode 100644 index 00000000..46cc78e6 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/config.py @@ -0,0 +1,359 @@ +"""Config views for the dashboard app""" +from __future__ import annotations + +import json +import re +from typing import Any + +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from rest_framework import status as http_status +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.helpers.constants import ( + COURSE_ACCESS_ROLES_STAFF_EDITOR, + FX_VIEW_DEFAULT_AUTH_CLASSES, +) +from futurex_openedx_extensions.helpers.converters import dict_to_hash, error_details_to_dictionary +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.models import ConfigAccessControl +from futurex_openedx_extensions.helpers.permissions import FXHasTenantAllCoursesAccess +from futurex_openedx_extensions.helpers.roles import FXViewRoleInfoMixin, add_course_access_roles +from futurex_openedx_extensions.helpers.tenants import ( + create_new_tenant_config, + delete_draft_tenant_config, + get_accessible_config_keys, + get_all_tenants_info, + get_draft_tenant_config, + get_tenant_config, + publish_tenant_config, + update_draft_tenant_config, +) + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('ConfigEditableInfoView.get') +class ConfigEditableInfoView(FXViewRoleInfoMixin, APIView): + """View to get the list of editable keys of the theme designer config""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'fx_config_editable_fields' + fx_view_description = 'api/fx/config/v1/editable: Get editable settings of config' + fx_default_read_write_roles = ['staff', 'fx_api_access_global'] + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + GET /api/fx/config/v1/editable/ + """ + tenant_id = self.verify_one_tenant_id_provided(request) + + return JsonResponse({ + 'editable_fields': get_accessible_config_keys( + user_id=request.user.id, + tenant_id=tenant_id, + writable_fields_filter=True, + ), + 'read_only_fields': get_accessible_config_keys( + user_id=request.user.id, + tenant_id=tenant_id, + writable_fields_filter=False, + ), + }) + + +@docs('ThemeConfigDraftView.get') +@docs('ThemeConfigDraftView.put') +@docs('ThemeConfigDraftView.delete') +class ThemeConfigDraftView(FXViewRoleInfoMixin, APIView): + """View to manage draft theme config""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + 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_default_read_write_roles = ['staff', 'fx_api_access_global'] + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + fx_tenant_id_url_arg_name: str = 'tenant_id' + + def get(self, request: Any, tenant_id: int) -> Response | JsonResponse: # pylint: disable=no-self-use + """Get draft config""" + updated_fields = get_draft_tenant_config(tenant_id=int(tenant_id)) + return JsonResponse({ + 'updated_fields': updated_fields, + 'draft_hash': dict_to_hash(updated_fields) + }) + + @staticmethod + def validate_input(current_revision_id: int) -> None: + """Validate the input""" + if current_revision_id is None: + raise KeyError('current_revision_id') + + try: + _ = int(current_revision_id) + except ValueError as exc: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='current_revision_id type must be numeric value.' + ) from exc + + def put(self, request: Any, tenant_id: int) -> Response: + """Update draft config""" + data = request.data + try: + key = data['key'] + if not isinstance(key, str): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, message='Key name must be a string.' + ) + + key_access_info = ConfigAccessControl.objects.get(key_name=key) + if not key_access_info.writable: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, message=f'Config Key: ({data["key"]}) is not writable.' + ) + + if 'reset' not in data and 'new_value' not in data: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, message='Provide either new_value or reset.' + ) + + new_value = data.get('new_value') + current_revision_id = data.get('current_revision_id') + reset = data.get('reset', False) is True + self.validate_input(current_revision_id) + + update_draft_tenant_config( + tenant_id=int(tenant_id), + config_path=key_access_info.path, + current_revision_id=int(current_revision_id), + new_value=new_value, + reset=reset, + user=request.user, + ) + + data = get_tenant_config(tenant_id=int(tenant_id), keys=[key], published_only=False) + return Response( + status=http_status.HTTP_200_OK, + data=serializers.TenantConfigSerializer(data, context={'request': request}).data, + ) + + 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: + if exc.code in [ + FXExceptionCodes.DRAFT_CONFIG_CREATE_MISMATCH.value, + FXExceptionCodes.DRAFT_CONFIG_UPDATE_MISMATCH.value, + FXExceptionCodes.DRAFT_CONFIG_DELETE_MISMATCH.value, + ]: + return Response( + error_details_to_dictionary(reason=f'({exc.code}) {str(exc)}'), + status=http_status.HTTP_409_CONFLICT + ) + return Response( + error_details_to_dictionary(reason=f'({exc.code}) {str(exc)}'), + status=http_status.HTTP_400_BAD_REQUEST + ) + except ConfigAccessControl.DoesNotExist: + return Response( + error_details_to_dictionary( + reason=f'Invalid 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: int) -> Response: # pylint: disable=no-self-use + """Delete draft config""" + delete_draft_tenant_config(tenant_id=int(tenant_id)) + return Response(status=http_status.HTTP_204_NO_CONTENT) + + +@docs('ThemeConfigPublishView.post') +class ThemeConfigPublishView(FXViewRoleInfoMixin, APIView): + """View to publish theme config""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'theme_config_publish' + fx_view_description = 'api/fx/config/v1/publish/: Get editable settings of config' + fx_default_read_write_roles = ['staff', 'fx_api_access_global'] + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + + @staticmethod + def validate_payload(data: dict, fx_permission_info: dict) -> dict: + """ + Validates the payload. + + :param data: The payload data from the request + :param fx_permission_info: The permission info + :raises FXCodedException: If the payload data is invalid + """ + tenant_id = data.get('tenant_id') + if not tenant_id or not isinstance(tenant_id, int): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Tenant id is required and must be an int.' + ) + + if tenant_id not in fx_permission_info['view_allowed_tenant_ids_full_access']: + raise PermissionDenied(detail=json.dumps( + {'reason': f'User does not have required access for tenant ({tenant_id})'} + )) + + draft_hash = data.get('draft_hash') + if not draft_hash or not isinstance(draft_hash, str): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Draft hash is required and must be a string.' + ) + current_draft = get_draft_tenant_config(tenant_id=tenant_id) + current_draft_hash = dict_to_hash(current_draft) + if current_draft_hash != draft_hash: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Draft hash mismatched with current draft values hash.' + ) + return current_draft + + @staticmethod + def rename_keys(updated_fields: dict) -> dict: + """ + Rename 'published_value' to 'old_value' and 'draft_value' to 'new_value + """ + renamed_data = {} + for key, value in updated_fields.items(): + renamed_data[key] = { + 'old_value': value.get('published_value', None), + 'new_value': value.get('draft_value', None) + } + return renamed_data + + def post(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + POST /api/fx/config/v1/publish/ + """ + data = request.data + updated_fields = self.validate_payload(data, self.request.fx_permission_info) + publish_tenant_config(data['tenant_id']) + return JsonResponse({'updated_fields': self.rename_keys(updated_fields)}) + + +@docs('ThemeConfigRetrieveView.get') +class ThemeConfigRetrieveView(FXViewRoleInfoMixin, APIView): + """View to get theme config values""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'theme_config_values' + fx_view_description = 'api/fx/config/v1/values/: Get theme config values' + fx_default_read_only_roles = ['staff', 'fx_api_access_global'] + + def validate_keys(self, tenant_id: int) -> list: + """Validate keys""" + keys = self.request.query_params.get('keys', '') + if keys: + return keys.split(',') + + return get_accessible_config_keys(user_id=self.request.user.id, tenant_id=tenant_id) + + def get(self, request: Any, *args: Any, **kwargs: Any) -> Response: + """ + GET /api/fx/config/v1/values/ + """ + tenant_id = self.verify_one_tenant_id_provided(request) + + data = get_tenant_config( + tenant_id, + self.validate_keys(tenant_id=tenant_id), + request.query_params.get('published_only', '0') == '1' + ) + return Response(serializers.TenantConfigSerializer(data, context={'request': request}).data) + + +@docs('ThemeConfigTenantView.post') +class ThemeConfigTenantView(FXViewRoleInfoMixin, APIView): + """View to create new Tenant and theme config""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'theme_config_tenant' + fx_view_description = 'api/fx/config/v1/tenant/: Create new Tenant' + + @staticmethod + def validate_payload(data: dict) -> None: + """ + Validates the payload. + + :param data: The payload data from the request + :raises FXCodedException: If the payload data is invalid + """ + sub_domain = data.get('sub_domain') + if not sub_domain: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Subdomain is required.' + ) + if not isinstance(sub_domain, str): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Subdomain must be a string.' + ) + if len(sub_domain) > 16: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Subdomain cannot exceed 16 characters.' + ) + if not re.match(r'^[a-zA-Z][a-zA-Z0-9]*$', sub_domain): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=( + 'Subdomain can only contain letters and numbers and cannot start with a number.' + ) + ) + + platform_name = data.get('platform_name') + if not platform_name: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Platform name is required.' + ) + if not isinstance(platform_name, str): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='Platform name must be a string.' + ) + + owner_user_id = data.get('owner_user_id') + if owner_user_id and not get_user_model().objects.filter(id=owner_user_id).exists(): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=f'User with ID {owner_user_id} does not exist.' + ) + + def post(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + POST /api/fx/config/v1/tenant/ + """ + data = request.data + self.validate_payload(data) + tenant_config = create_new_tenant_config(data['sub_domain'], data['platform_name']) + owner_user_id = data.get('owner_user_id') + if owner_user_id: + add_course_access_roles( + caller=self.fx_permission_info['user'], + tenant_ids=[tenant_config.id], + user_keys=[data['owner_user_id']], + role=COURSE_ACCESS_ROLES_STAFF_EDITOR, + tenant_wide=True, + course_ids=[], + ) + + result = {'tenant_id': tenant_config.id} + result.update(get_all_tenants_info()['info'].get(tenant_config.id)) + return JsonResponse(result) diff --git a/futurex_openedx_extensions/dashboard/views/courses.py b/futurex_openedx_extensions/dashboard/views/courses.py new file mode 100644 index 00000000..46d869ff --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/courses.py @@ -0,0 +1,245 @@ +"""Courses views for the dashboard app""" +from __future__ import annotations + +from typing import Any + +from django.db.models.query import QuerySet +from django.http import JsonResponse +from rest_framework import status as http_status +from rest_framework.generics import ListAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.details.courses import ( + get_courses_feedback_queryset, + get_courses_queryset, +) +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.dashboard.statistics.courses import ( + get_courses_count_by_status, + get_courses_ratings, +) +from futurex_openedx_extensions.helpers.constants import ( + COURSE_STATUS_SELF_PREFIX, + COURSE_STATUSES, + FX_VIEW_DEFAULT_AUTH_CLASSES, +) +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.export_mixins import ExportCSVMixin +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter +from futurex_openedx_extensions.helpers.library import get_accessible_libraries +from futurex_openedx_extensions.helpers.pagination import DefaultPagination +from futurex_openedx_extensions.helpers.permissions import FXHasTenantCourseAccess +from futurex_openedx_extensions.helpers.roles import FXViewRoleInfoMixin + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('CoursesView.get') +@docs('CoursesView.post') +class CoursesView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): + """View to get the list of courses""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + serializer_class = serializers.CourseDetailsSerializer + pagination_class = DefaultPagination + filter_backends = [DefaultOrderingFilter] + ordering_fields = [ + 'id', 'self_paced', 'enrolled_count', 'active_count', + 'certificates_count', 'display_name', 'org', 'completion_rate', + ] + ordering = ['display_name'] + fx_view_name = 'courses_list' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/courses/v1/courses/: Get the list of courses' + + def get_queryset(self) -> QuerySet: + """Get the list of learners""" + search_text = self.request.query_params.get('search_text') + include_staff = self.request.query_params.get('include_staff') + + return get_courses_queryset( + fx_permission_info=self.fx_permission_info, + search_text=search_text, + visible_filter=None, + include_staff=include_staff, + ) + + def post(self, request: Any) -> Response | JsonResponse: # pylint: disable=no-self-use + """POST /api/fx/courses/v1/courses/""" + serializer = serializers.CourseCreateSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + created_course = serializer.save() + return JsonResponse({ + 'id': str(created_course.id), + 'url': serializer.get_absolute_url(), + }) + + return Response( + {'errors': serializer.errors}, + status=http_status.HTTP_400_BAD_REQUEST + ) + + +@docs('LibraryView.get') +@docs('LibraryView.post') +class LibraryView(ExportCSVMixin, FXViewRoleInfoMixin, APIView): + """View to get the list of libraries""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'libraries_list' + fx_default_read_only_roles = ['staff', 'instructor', 'library_user', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/libraries/v1/libraries/: Get the list of libraries' + + def get(self, request: Any) -> Response: + """ + GET /api/fx/libraries/v1/libraries/?tenant_ids= + + (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, + the API will assume the list of all accessible tenants by the user + """ + libraries = get_accessible_libraries(self.fx_permission_info, self.request.query_params.get('search_text')) + paginator = self.pagination_class() + page = paginator.paginate_queryset(libraries, request) + serializer = serializers.LibrarySerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def post(self, request: Any) -> Response: # pylint: disable=no-self-use + """ + POST /api/fx/libraries/v1/libraries/ + """ + serializer = serializers.LibrarySerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + created_library = serializer.save() + return JsonResponse( + {'library': str(created_library.location.library_key)}, + status=http_status.HTTP_201_CREATED, + ) + return Response( + {'errors': serializer.errors}, + status=http_status.HTTP_400_BAD_REQUEST + ) + + +@docs('CourseStatusesView.get') +class CourseStatusesView(FXViewRoleInfoMixin, APIView): + """View to get the course statuses""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'course_statuses' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/statistics/v1/course_statuses/: Get the course statuses' + + @staticmethod + def to_json(result: QuerySet) -> dict[str, int]: + """Convert the result to JSON format""" + dict_result = { + f'{COURSE_STATUS_SELF_PREFIX if self_paced else ""}{status}': 0 + for status in COURSE_STATUSES + for self_paced in [False, True] + } + + for item in result: + status = f'{COURSE_STATUS_SELF_PREFIX if item["self_paced"] else ""}{item["status"]}' + dict_result[status] = item['courses_count'] + return dict_result + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + GET /api/fx/statistics/v1/course_statuses/?tenant_ids= + + (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, + the API will assume the list of all accessible tenants by the user + """ + result = get_courses_count_by_status(fx_permission_info=self.fx_permission_info) + + return JsonResponse(self.to_json(result)) + + +@docs('CoursesFeedbackView.get') +class CoursesFeedbackView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): + """View to get the list of courses feedbacks""" + authentication_classes = default_auth_classes + serializer_class = serializers.CoursesFeedbackSerializer + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'courses_feedback' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/courses/v1/feedback: Get the list of feedbacks' + + def validate_rating_list(self, param_key: str) -> list[int] | None: + """ + Validates that the input string from query parameters is a comma-separated list + of integers between 1 and 5. Returns the parsed list if valid. + + :param param_key: The key in query params to validate (e.g. 'rating_content') + :return: List of integers if valid, or None if not provided + :raises: FXCodedException if validation fails + """ + value = self.request.query_params.get(param_key) + if not value: + return None + + try: + ratings = [int(r.strip()) for r in value.split(',')] + except ValueError as exc: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=f"'{param_key}' must be a comma-separated list of valid integers." + ) from exc + + if any(r < 0 or r > 5 for r in ratings): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=f"Each value in '{param_key}' must be between 0 and 5 (inclusive)." + ) + + return ratings + + def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: + """Get the list of course feedbacks""" + course_ids = self.request.query_params.get('course_ids', '') + course_ids_list = [ + course.strip() for course in course_ids.split(',') + ] if course_ids else None + + return get_courses_feedback_queryset( + fx_permission_info=self.request.fx_permission_info, + course_ids=course_ids_list, + public_only=self.request.query_params.get('public_only', '0') == '1', + recommended_only=self.request.query_params.get('recommended_only', '0') == '1', + feedback_search=self.request.query_params.get('feedback_search'), + rating_content_filter=self.validate_rating_list('rating_content'), + rating_instructors_filter=self.validate_rating_list('rating_instructors') + ) + + +@docs('GlobalRatingView.get') +class GlobalRatingView(FXViewRoleInfoMixin, APIView): + """View to get the global rating""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'global_rating' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/statistics/v1/rating/: Get the global rating for courses' + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + GET /api/fx/statistics/v1/rating/?tenant_ids= + + (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, + the API will assume the list of all accessible tenants by the user + """ + data_result = get_courses_ratings(fx_permission_info=self.fx_permission_info) + result = { + 'total_rating': data_result['total_rating'], + 'total_count': sum(data_result[f'rating_{index}_count'] for index in range(1, 6)), + 'courses_count': data_result['courses_count'], + 'rating_counts': { + str(index): data_result[f'rating_{index}_count'] for index in range(1, 6) + }, + } + + return JsonResponse(result) diff --git a/futurex_openedx_extensions/dashboard/views/learners.py b/futurex_openedx_extensions/dashboard/views/learners.py new file mode 100644 index 00000000..74619d04 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/learners.py @@ -0,0 +1,222 @@ +"""Learners views for the dashboard app""" +from __future__ import annotations + +from typing import Any + +from django.db.models.query import QuerySet +from django.http import JsonResponse +from rest_framework import status as http_status +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.details.courses import get_learner_courses_info_queryset +from futurex_openedx_extensions.dashboard.details.learners import ( + get_learner_info_queryset, + get_learners_by_course_queryset, + get_learners_enrollments_queryset, + get_learners_queryset, +) +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.helpers.constants import FX_VIEW_DEFAULT_AUTH_CLASSES +from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.export_mixins import ExportCSVMixin +from futurex_openedx_extensions.helpers.pagination import DefaultPagination +from futurex_openedx_extensions.helpers.permissions import FXHasTenantCourseAccess +from futurex_openedx_extensions.helpers.roles import FXViewRoleInfoMixin +from rest_framework.generics import ListAPIView + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('LearnersView.get') +class LearnersView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): + """View to get the list of learners""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + serializer_class = serializers.LearnerDetailsSerializer + pagination_class = DefaultPagination + fx_view_name = 'learners_list' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/learners/: Get the list of learners' + + def get_queryset(self) -> QuerySet: + """Get the list of learners""" + search_text = self.request.query_params.get('search_text') + include_staff = self.request.query_params.get('include_staff', '0') == '1' + min_enrollments_count = self.request.query_params.get('min_enrollments_count', -1) + max_enrollments_count = self.request.query_params.get('max_enrollments_count', -1) + + try: + min_enrollments_count = int(min_enrollments_count) + max_enrollments_count = int(max_enrollments_count) + except ValueError: + pass # let get_learners_queryset handle the invalid values + + return get_learners_queryset( + fx_permission_info=self.fx_permission_info, + search_text=search_text, + include_staff=include_staff, + enrollments_filter=(min_enrollments_count, max_enrollments_count), + ) + + +@docs('LearnerInfoView.get') +class LearnerInfoView(FXViewRoleInfoMixin, APIView): + """View to get the information of a learner""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'learner_detailed_info' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/learner/: Get the information of a learner' + + def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonResponse | Response: + """ + GET /api/fx/learners/v1/learner// + """ + include_staff = request.query_params.get('include_staff', '0') == '1' + + try: + user = get_learner_info_queryset( + fx_permission_info=self.fx_permission_info, + user_key=username, + include_staff=include_staff, + ).first() + except FXCodedException as exc: + return_status = http_status.HTTP_404_NOT_FOUND if exc.code in ( + FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, FXExceptionCodes.USER_NOT_FOUND.value, + ) else http_status.HTTP_400_BAD_REQUEST + + return Response( + error_details_to_dictionary(reason=str(exc)), + status=return_status, + ) + + return JsonResponse( + serializers.LearnerDetailsExtendedSerializer(user, context={'request': request}).data + ) + + +@docs('LearnerCoursesView.get') +class LearnerCoursesView(FXViewRoleInfoMixin, APIView): + """View to get the list of courses for a learner""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'learner_courses' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/learner_courses/: Get the list of courses for a learner' + + def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonResponse | Response: + """ + GET /api/fx/learners/v1/learner_courses// + """ + include_staff = request.query_params.get('include_staff', '0') == '1' + + try: + courses = get_learner_courses_info_queryset( + fx_permission_info=self.fx_permission_info, + user_key=username, + visible_filter=None, + include_staff=include_staff, + ) + except FXCodedException as exc: + return_status = http_status.HTTP_404_NOT_FOUND if exc.code in ( + FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, FXExceptionCodes.USER_NOT_FOUND.value, + ) else http_status.HTTP_400_BAD_REQUEST + + return Response( + error_details_to_dictionary(reason=str(exc)), + status=return_status, + ) + + return Response(serializers.LearnerCoursesDetailsSerializer( + courses, context={'request': request}, many=True + ).data) + + +@docs('LearnersDetailsForCourseView.get') +class LearnersDetailsForCourseView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): + """View to get the list of learners for a course""" + authentication_classes = default_auth_classes + serializer_class = serializers.LearnerDetailsForCourseSerializer + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'learners_with_details_for_course' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/learners/: Get the list of learners for a course' + + def get_related_id(self) -> None: + """ + Related ID is course_id for this view. + """ + return self.kwargs.get('course_id') + + def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: + """Get the list of learners for a course""" + search_text = self.request.query_params.get('search_text') + course_id = self.kwargs.get('course_id') + include_staff = self.request.query_params.get('include_staff', '0') == '1' + + return get_learners_by_course_queryset( + course_id=course_id, + search_text=search_text, + include_staff=include_staff, + ) + + def get_serializer_context(self) -> dict[str, Any]: + """Get the serializer context""" + context = super().get_serializer_context() + context['course_id'] = self.kwargs.get('course_id') + context['omit_subsection_name'] = self.request.query_params.get('omit_subsection_name', '0') + return context + + +@docs('LearnersEnrollmentView.get') +class LearnersEnrollmentView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): + """View to get the list of learners for a course""" + authentication_classes = default_auth_classes + serializer_class = serializers.LearnerEnrollmentSerializer + permission_classes = [FXHasTenantCourseAccess] + pagination_class = DefaultPagination + fx_view_name = 'learners_enrollment_details' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/learners/v1/enrollments: Get the list of enrollments' + is_single_course_requested = False + + def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: + """Get the list of learners for a course""" + course_ids = self.request.query_params.get('course_ids', '') + user_ids = self.request.query_params.get('user_ids', '') + usernames = self.request.query_params.get('usernames', '') + course_ids_list = [ + course.strip() for course in course_ids.split(',') + ] if course_ids else None + user_ids_list = [ + int(user.strip()) for user in user_ids.split(',') if user.strip().isdigit() + ] if user_ids else None + usernames_list = [ + username.strip() for username in usernames.split(',') + ] if usernames else None + + if course_ids_list and len(course_ids_list) == 1: + self.is_single_course_requested = True + + return get_learners_enrollments_queryset( + fx_permission_info=self.request.fx_permission_info, + user_ids=user_ids_list, + course_ids=course_ids_list, + usernames=usernames_list, + learner_search=self.request.query_params.get('learner_search'), + course_search=self.request.query_params.get('course_search'), + include_staff=self.request.query_params.get('include_staff', '0') == '1', + ) + + def get_serializer_context(self) -> dict[str, Any]: + """Get the serializer context""" + context = super().get_serializer_context() + if self.is_single_course_requested and self.get_queryset().exists(): + context['course_id'] = str(self.get_queryset().first().course_id) + context['omit_subsection_name'] = self.request.query_params.get('omit_subsection_name', '0') + return context diff --git a/futurex_openedx_extensions/dashboard/views/roles.py b/futurex_openedx_extensions/dashboard/views/roles.py new file mode 100644 index 00000000..e57b4f0d --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/roles.py @@ -0,0 +1,228 @@ +"""Roles views for the dashboard app""" +from __future__ import annotations + +from typing import Any, Dict + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models.query import QuerySet +from django.http import JsonResponse +from edx_api_doc_tools import exclude_schema_for +from rest_framework import status as http_status +from rest_framework import viewsets +from rest_framework.exceptions import ParseError +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.helpers.constants import ( + COURSE_ACCESS_ROLES_SUPPORTED_READ, + FX_VIEW_DEFAULT_AUTH_CLASSES, +) +from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.pagination import DefaultPagination +from futurex_openedx_extensions.helpers.permissions import ( + FXHasTenantAllCoursesAccess, + FXHasTenantCourseAccess, +) +from futurex_openedx_extensions.helpers.roles import ( + FXViewRoleInfoMixin, + add_course_access_roles, + delete_course_access_roles, + get_course_access_roles_queryset, + update_course_access_roles, +) +from futurex_openedx_extensions.helpers.users import get_user_by_key + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('UserRolesManagementView.create') +@docs('UserRolesManagementView.destroy') +@docs('UserRolesManagementView.list') +@docs('UserRolesManagementView.retrieve') +@docs('UserRolesManagementView.update') +@exclude_schema_for('partial_update') +class UserRolesManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors + """View to get the user roles""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantAllCoursesAccess] + fx_view_name = 'user_roles' + fx_default_read_only_roles = ['org_course_creator_group'] + fx_default_read_write_roles = ['org_course_creator_group'] + fx_allowed_write_methods = ['POST', 'PUT', 'DELETE'] + fx_view_description = 'api/fx/roles/v1/user_roles/: user roles management APIs' + + lookup_field = 'username' + lookup_value_regex = '[^/]+' + serializer_class = serializers.UserRolesSerializer + pagination_class = DefaultPagination + + @transaction.non_atomic_requests + def dispatch(self, *args: Any, **kwargs: Any) -> Response: + return super().dispatch(*args, **kwargs) + + def get_queryset(self) -> QuerySet: + """Get the list of users""" + dummy_serializers = serializers.UserRolesSerializer(context={'request': self.request}) + + try: + q_set = get_user_model().objects.filter( + id__in=get_course_access_roles_queryset( + orgs_filter=dummy_serializers.orgs_filter, + remove_redundant=True, + users=None, + search_text=dummy_serializers.query_params['search_text'], + roles_filter=dummy_serializers.query_params['roles_filter'], + active_filter=dummy_serializers.query_params['active_filter'], + course_ids_filter=dummy_serializers.query_params['course_ids_filter'], + excluded_role_types=dummy_serializers.query_params['excluded_role_types'], + excluded_hidden_roles=not dummy_serializers.query_params['include_hidden_roles'], + ).values('user_id').distinct().order_by() + ).select_related('profile').order_by('id') + except (ValueError, FXCodedException) as exc: + raise ParseError(f'Invalid parameter: {exc}') from exc + + return q_set + + def create(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse: + """Create a new user role""" + data = request.data + try: + if ( + not isinstance(data['tenant_ids'], list) or + not all(isinstance(t_id, int) for t_id in data['tenant_ids']) + ): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='tenant_ids must be a list of integers', + ) + + if not isinstance(data['users'], list): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='users must be a list', + ) + + if not isinstance(data['role'], str): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='role must be a string', + ) + + if not isinstance(data['tenant_wide'], int): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='tenant_wide must be an integer flag', + ) + + if not isinstance(data.get('course_ids', []), list): + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message='course_ids must be a list', + ) + + result = add_course_access_roles( + caller=self.fx_permission_info['user'], + tenant_ids=data['tenant_ids'], + user_keys=data['users'], + role=data['role'], + tenant_wide=data['tenant_wide'] != 0, + course_ids=data.get('course_ids', []), + ) + 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 + ) + + return JsonResponse( + result, + status=http_status.HTTP_201_CREATED, + ) + + @staticmethod + def verify_username(username: str) -> Response | Dict[str, Any]: + """Verify the username""" + user_info = get_user_by_key(username) + if not user_info['user']: + return Response( + error_details_to_dictionary(reason=f'({user_info["error_code"]}) {user_info["error_message"]}'), + status=http_status.HTTP_404_NOT_FOUND + ) + return user_info + + def update(self, request: Any, *args: Any, **kwargs: Any) -> Response: + """Update a user role""" + user_info = self.verify_username(kwargs['username']) + if isinstance(user_info, Response): + return user_info + + result = update_course_access_roles( + caller=self.fx_permission_info['user'], + user=user_info['user'], + new_roles_details=request.data or {}, + dry_run=False, + ) + + if result['error_code']: + return Response( + error_details_to_dictionary(reason=f'({result["error_code"]}) {result["error_message"]}'), + status=http_status.HTTP_400_BAD_REQUEST + ) + + return Response( + self.serializer_class(user_info['user'], context={'request': request}).data, + status=http_status.HTTP_200_OK, + ) + + def destroy(self, request: Any, *args: Any, **kwargs: Any) -> Response: + """Delete a user role""" + if not request.query_params.get('tenant_ids'): + return Response( + error_details_to_dictionary(reason="Missing required parameter: 'tenant_ids'"), + status=http_status.HTTP_400_BAD_REQUEST + ) + + user_info = self.verify_username(kwargs['username']) + if isinstance(user_info, Response): + return user_info + + try: + delete_course_access_roles( + caller=self.fx_permission_info['user'], + tenant_ids=self.fx_permission_info['view_allowed_tenant_ids_any_access'], + user=user_info['user'], + ) + except FXCodedException as exc: + return Response( + error_details_to_dictionary(reason=str(exc)), + status=http_status.HTTP_404_NOT_FOUND + ) + + return Response(status=http_status.HTTP_204_NO_CONTENT) + + +@docs('MyRolesView.get') +class MyRolesView(FXViewRoleInfoMixin, APIView): + """View to get the user roles of the caller""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'my_roles' + fx_default_read_only_roles = COURSE_ACCESS_ROLES_SUPPORTED_READ.copy() + fx_view_description = 'api/fx/roles/v1/my_roles/: user roles management APIs' + + serializer_class = serializers.UserRolesSerializer + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """Get the list of users""" + data = serializers.UserRolesSerializer(self.fx_permission_info['user'], context={'request': request}).data + data['is_system_staff'] = self.fx_permission_info['is_system_staff_user'] + return JsonResponse(data) diff --git a/futurex_openedx_extensions/dashboard/views/statistics.py b/futurex_openedx_extensions/dashboard/views/statistics.py new file mode 100644 index 00000000..f1634889 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/statistics.py @@ -0,0 +1,418 @@ +"""Statistics views for the dashboard app""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any + +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.core.exceptions import ValidationError +from django.http import JsonResponse +from rest_framework.exceptions import ParseError +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.dashboard.statistics.certificates import ( + get_certificates_count, + get_learning_hours_count, +) +from futurex_openedx_extensions.dashboard.statistics.courses import ( + get_courses_count, + get_enrollments_count, + get_enrollments_count_aggregated, +) +from futurex_openedx_extensions.dashboard.statistics.learners import get_learners_count +from futurex_openedx_extensions.helpers.constants import FX_VIEW_DEFAULT_AUTH_CLASSES +from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes +from futurex_openedx_extensions.helpers.permissions import ( + FXHasTenantCourseAccess, + get_tenant_limited_fx_permission_info, +) +from futurex_openedx_extensions.helpers.roles import FXViewRoleInfoMixin + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('TotalCountsView.get') +class TotalCountsView(FXViewRoleInfoMixin, APIView): + """ + View to get the total count statistics + + TODO: there is a better way to get info per tenant without iterating over all tenants + """ + STAT_CERTIFICATES = 'certificates' + STAT_COURSES = 'courses' + STAT_ENROLLMENTS = 'enrollments' + STAT_HIDDEN_COURSES = 'hidden_courses' + STAT_LEARNERS = 'learners' + STAT_LEARNING_HOURS = 'learning_hours' + STAT_UNIQUE_LEARNERS = 'unique_learners' + + STAT_RESULT_KEYS = { + STAT_CERTIFICATES: 'certificates_count', + STAT_COURSES: 'courses_count', + STAT_ENROLLMENTS: 'enrollments_count', + STAT_HIDDEN_COURSES: 'hidden_courses_count', + STAT_LEARNERS: 'learners_count', + STAT_LEARNING_HOURS: 'learning_hours_count', + STAT_UNIQUE_LEARNERS: 'unique_learners', + } + + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'total_counts_statistics' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/statistics/v1/total_counts/: Get the total count statistics' + + def __init__(self, **kwargs: Any) -> None: + """Initialize the view""" + super().__init__() + self.valid_stats = [ + self.STAT_CERTIFICATES, self.STAT_COURSES, self.STAT_ENROLLMENTS, self.STAT_HIDDEN_COURSES, + self.STAT_LEARNERS, self.STAT_LEARNING_HOURS, self.STAT_UNIQUE_LEARNERS, + ] + self.stats: list[str] = [] + self.include_staff = False + self.tenant_ids: list[int] = [] + + def _get_certificates_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of certificates for the given tenant""" + collector_result = get_certificates_count(one_tenant_permission_info, include_staff=self.include_staff) + return sum(certificate_count for certificate_count in collector_result.values()) + + @staticmethod + def _get_courses_count_data(one_tenant_permission_info: dict, visible_filter: bool | None) -> int: + """Get the count of courses for the given tenant""" + collector_result = get_courses_count(one_tenant_permission_info, visible_filter=visible_filter) + return sum(org_count['courses_count'] for org_count in collector_result) + + def _get_enrollments_count_data(self, one_tenant_permission_info: dict, visible_filter: bool | None) -> int: + """Get the count of enrollments for the given tenant""" + collector_result = get_enrollments_count( + one_tenant_permission_info, visible_filter=visible_filter, include_staff=self.include_staff, + ) + return sum(org_count['enrollments_count'] for org_count in collector_result) + + def _get_learners_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of learners for the given tenant""" + return get_learners_count(one_tenant_permission_info, include_staff=self.include_staff) + + def _get_learning_hours_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of learning_hours for the given tenant""" + return get_learning_hours_count(one_tenant_permission_info, include_staff=self.include_staff) + + def _get_stat_count(self, stat: str, tenant_id: int) -> Any: + """Get the count of the given stat for the given tenant""" + if stat == self.STAT_UNIQUE_LEARNERS: + return get_learners_count(self.fx_permission_info, self.include_staff) + + one_tenant_permission_info = get_tenant_limited_fx_permission_info(self.fx_permission_info, tenant_id) + if stat == self.STAT_CERTIFICATES: + result = self._get_certificates_count_data(one_tenant_permission_info) + + elif stat == self.STAT_COURSES: + result = self._get_courses_count_data(one_tenant_permission_info, visible_filter=True) + + elif stat == self.STAT_ENROLLMENTS: + result = self._get_enrollments_count_data(one_tenant_permission_info, visible_filter=True) + + elif stat == self.STAT_HIDDEN_COURSES: + result = self._get_courses_count_data(one_tenant_permission_info, visible_filter=False) + + elif stat == self.STAT_LEARNING_HOURS: + result = self._get_learning_hours_count_data(one_tenant_permission_info) + + else: + result = self._get_learners_count_data(one_tenant_permission_info) + + return result + + def _load_query_params(self, request: Any) -> None: + """Load the query parameters""" + self.stats = request.query_params.get('stats', '').split(',') + invalid_stats = list(set(self.stats) - set(self.valid_stats)) + if invalid_stats: + raise ParseError(f'Invalid stats type: {invalid_stats}') + self.include_staff = request.query_params.get('include_staff', '0') == '1' + self.tenant_ids = self.fx_permission_info['view_allowed_tenant_ids_any_access'] + + def _construct_result(self) -> dict: + """Construct the result dictionary""" + if self.STAT_UNIQUE_LEARNERS in self.stats: + total_unique_learners = self._get_stat_count(self.STAT_UNIQUE_LEARNERS, 0) + self.stats.remove(self.STAT_UNIQUE_LEARNERS) + else: + total_unique_learners = None + result: dict[Any, Any] = dict({tenant_id: {} for tenant_id in self.tenant_ids}) + result.update({ + f'total_{self.STAT_RESULT_KEYS[stat]}': 0 for stat in self.stats + }) + + for tenant_id in self.tenant_ids: + for stat in self.stats: + count = int(self._get_stat_count(stat, tenant_id)) + result[tenant_id][self.STAT_RESULT_KEYS[stat]] = count + result[f'total_{self.STAT_RESULT_KEYS[stat]}'] += count + + if total_unique_learners is not None: + result['total_unique_learners'] = total_unique_learners + + result['limited_access'] = self.fx_permission_info['view_allowed_course_access_orgs'] != [] + + return result + + def get(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse: + """Returns the total count statistics for the selected tenants.""" + self._load_query_params(request) + + return JsonResponse(self._construct_result()) + + +@docs('AggregatedCountsView.get') +class AggregatedCountsView(TotalCountsView): # pylint: disable=too-many-instance-attributes + """ + View to get the aggregated count statistics + """ + AGGREGATE_PERIOD_DAY = 'day' + AGGREGATE_PERIOD_MONTH = 'month' + AGGREGATE_PERIOD_QUARTER = 'quarter' + AGGREGATE_PERIOD_YEAR = 'year' + + VALID_AGGREGATE_PERIOD = [ + AGGREGATE_PERIOD_DAY, AGGREGATE_PERIOD_MONTH, AGGREGATE_PERIOD_YEAR, AGGREGATE_PERIOD_QUARTER, + ] + + fx_view_name = 'aggregated_counts_statistics' + fx_view_description = 'api/fx/statistics/v1/aggregated_counts/: Get the total count statistics with aggregate' + + def __init__(self, **kwargs: Any) -> None: + """Initialize the view""" + super().__init__() + self.valid_stats = [self.STAT_ENROLLMENTS] + self.aggregate_period = self.AGGREGATE_PERIOD_DAY + self.date_to: date | None = None + self.date_from: date | None = None + self.favors_backward = True + self.max_period_chunks = 0 + self.fill_missing_periods = True + + def _load_query_params(self, request: Any) -> None: + """Load the query parameters""" + super()._load_query_params(request) + + aggregate_period = request.query_params.get('aggregate_period') + if aggregate_period is None or aggregate_period not in self.VALID_AGGREGATE_PERIOD: + raise ParseError(f'Invalid aggregate_period: {aggregate_period}') + + self.favors_backward = request.query_params.get('favors_backward', '1') == '1' + + try: + self.max_period_chunks = int(request.query_params.get('max_period_chunks', 0)) + except ValueError as exc: + raise ParseError('Invalid max_period_chunks. It must be an integer.') from exc + + if self.max_period_chunks < 0 or self.max_period_chunks > settings.FX_MAX_PERIOD_CHUNKS_MAP[aggregate_period]: + self.max_period_chunks = 0 + + self.aggregate_period = aggregate_period + + self.fill_missing_periods = request.query_params.get('fill_missing_periods', '1') == '1' + + date_from = request.query_params.get('date_from') + date_to = request.query_params.get('date_to') + + try: + self.date_from = datetime.strptime(date_from, '%Y-%m-%d').date() if date_from else None + self.date_to = datetime.strptime(date_to, '%Y-%m-%d').date() if date_to else None + except (ValueError, TypeError) as exc: + raise ParseError( + 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' + ) from exc + + def _get_certificates_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of certificates for the given tenant""" + raise NotImplementedError('Certificates count is not supported for aggregated counts yet') + + @staticmethod + def _get_courses_count_data(one_tenant_permission_info: dict, visible_filter: bool | None) -> int: + """Get the count of courses for the given tenant""" + raise NotImplementedError('Courses count is not supported for aggregated counts yet') + + def _get_enrollments_count_data( # type: ignore + self, one_tenant_permission_info: dict, visible_filter: bool | None, + ) -> tuple[list, datetime | None, datetime | None]: + """Get the count of enrollments for the given tenant""" + collector_result, calculated_from, calculated_to = get_enrollments_count_aggregated( + one_tenant_permission_info, + visible_filter=visible_filter, + include_staff=self.include_staff, + aggregate_period=self.aggregate_period, + date_from=self.date_from, + date_to=self.date_to, + favors_backward=self.favors_backward, + max_period_chunks=self.max_period_chunks, + ) + return [ + {'label': item['period'], 'value': item['enrollments_count']} for item in collector_result + ], calculated_from, calculated_to + + def _get_learners_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of learners for the given tenant""" + raise NotImplementedError('Learners count is not supported for aggregated counts yet') + + def _get_learning_hours_count_data(self, one_tenant_permission_info: dict) -> int: + """Get the count of learning_hours for the given tenant""" + raise NotImplementedError('Learning hours count is not supported for aggregated counts yet') + + @staticmethod + def get_period_label(aggregate_period: str, the_date: date | datetime) -> str: + """Get the period label""" + if not isinstance(the_date, (date, datetime)): + raise ValidationError(f'the_date must be a date or datetime object. Got ({the_date.__class__.__name__})') + + match aggregate_period: + case AggregatedCountsView.AGGREGATE_PERIOD_DAY: + result = the_date.strftime('%Y-%m-%d') + + case AggregatedCountsView.AGGREGATE_PERIOD_MONTH: + result = the_date.strftime('%Y-%m') + + case AggregatedCountsView.AGGREGATE_PERIOD_QUARTER: + result = f'{the_date.year}-Q{((the_date.month - 1) // 3) + 1}' + + case AggregatedCountsView.AGGREGATE_PERIOD_YEAR: + result = str(the_date.year) + + case _: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=f'Invalid aggregate_period: {aggregate_period}', + ) + + return result + + @staticmethod + def get_next_period_date(aggregate_period: str, the_date: date | datetime) -> date | datetime: + """Get the next period date""" + if not isinstance(the_date, (date, datetime)): + raise ValidationError(f'the_date must be a date or datetime object. Got ({the_date.__class__.__name__})') + + match aggregate_period: + case AggregatedCountsView.AGGREGATE_PERIOD_DAY: + result = the_date + timedelta(days=1) + + case AggregatedCountsView.AGGREGATE_PERIOD_MONTH: + result = the_date.replace(day=1) + relativedelta(months=1) + + case AggregatedCountsView.AGGREGATE_PERIOD_QUARTER: + result = the_date.replace(day=1).replace( + month=((the_date.month - 1) // 3) * 3 + 1, + ) + relativedelta(months=3) + + case AggregatedCountsView.AGGREGATE_PERIOD_YEAR: + result = the_date.replace(day=1, month=1) + relativedelta(years=1) + + case _: + raise FXCodedException( + code=FXExceptionCodes.INVALID_INPUT, + message=f'Invalid aggregate_period: {aggregate_period}', + ) + + return result + + def get_data_with_missing_periods( + self, data: list[dict[str, Any]], already_sorted: bool = False, + ) -> list[dict[str, Any]]: + """Get the date with missing periods.""" + data = sorted(data, key=lambda x: x['label']) if not already_sorted else data + + if not self.date_from or not self.date_to: + return data + + result = [] + current_date = self.date_from + for item in data: + current_label = self.get_period_label(self.aggregate_period, current_date) + while item['label'] != current_label: + result.append({'label': current_label, 'value': 0}) + current_date = self.get_next_period_date(self.aggregate_period, current_date) + current_label = self.get_period_label(self.aggregate_period, current_date) + if current_date > self.date_to: + break + if current_date > self.date_to: + break + result.append(item) + current_date = self.get_next_period_date(self.aggregate_period, current_date) + + while current_date <= self.date_to: + result.append({'label': self.get_period_label(self.aggregate_period, current_date), 'value': 0}) + current_date = self.get_next_period_date(self.aggregate_period, current_date) + + return result + + def _construct_result(self) -> dict: + """Construct the result dictionary""" + result: dict[Any, Any] = { + 'query_settings': { + 'aggregate_period': self.aggregate_period, + }, + 'by_tenant': [], + 'all_tenants': { + self.STAT_RESULT_KEYS[stat]: [] for stat in self.stats + }, + } + + all_tenants = result['all_tenants'] + all_tenants['totals'] = { + self.STAT_RESULT_KEYS[stat]: 0 for stat in self.stats + } + _by_period: dict[str, Any] = { + self.STAT_RESULT_KEYS[stat]: {} for stat in self.stats + } + for tenant_id in self.tenant_ids: + tenant_data: dict[str, Any] = { + 'tenant_id': tenant_id, + 'totals': {}, + } + for stat in self.stats: + key = self.STAT_RESULT_KEYS[stat] + data = self._get_stat_count(stat, tenant_id) + self.date_from = data[1] + self.date_to = data[2] + + if self.fill_missing_periods: + full_details = self.get_data_with_missing_periods(data[0], already_sorted=True) + else: + full_details = data[0] + tenant_data[key] = full_details + count = sum(item['value'] for item in full_details) + tenant_data['totals'][key] = count + + all_tenants['totals'][key] += count + for item in full_details: + _by_period[key][item['label']] = _by_period[key].get(item['label'], 0) + item['value'] + + result['by_tenant'].append(tenant_data) + + for stat in self.stats: + key = self.STAT_RESULT_KEYS[stat] + _by_period[key] = dict(sorted(_by_period[key].items())) + for item in _by_period[key]: + all_tenants[key].append({ + 'label': item, + 'value': _by_period[key][item], + }) + + result['limited_access'] = self.fx_permission_info['view_allowed_course_access_orgs'] != [] + result['query_settings']['date_from'] = self.date_from + result['query_settings']['date_to'] = self.date_to + + return result + + def get(self, request: Any, *args: Any, **kwargs: Any) -> Response: + """Returns the total count statistics for the selected tenants.""" + self._load_query_params(request) + + return Response(serializers.AggregatedCountsSerializer(self._construct_result()).data) diff --git a/futurex_openedx_extensions/dashboard/views/system.py b/futurex_openedx_extensions/dashboard/views/system.py new file mode 100644 index 00000000..7dc1917a --- /dev/null +++ b/futurex_openedx_extensions/dashboard/views/system.py @@ -0,0 +1,340 @@ +"""System views for the dashboard app""" +from __future__ import annotations + +from typing import Any, Dict +from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit + +from common.djangoapps.student.models import get_user_by_username_or_email +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.paginator import EmptyPage +from django.db.models.query import QuerySet +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status as http_status +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.views import APIView + +from futurex_openedx_extensions.dashboard import serializers +from futurex_openedx_extensions.dashboard.docs_utils import docs +from futurex_openedx_extensions.helpers import clickhouse_operations as ch +from futurex_openedx_extensions.helpers.constants import ( + CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS, + CLICKHOUSE_FX_BUILTIN_ORG_IN_TENANTS, + COURSE_ACCESS_ROLES_SUPPORTED_READ, + FX_VIEW_DEFAULT_AUTH_CLASSES, +) +from futurex_openedx_extensions.helpers.converters import error_details_to_dictionary +from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter, DefaultSearchFilter +from futurex_openedx_extensions.helpers.models import ClickhouseQuery, DataExportTask +from futurex_openedx_extensions.helpers.pagination import DefaultPagination +from futurex_openedx_extensions.helpers.permissions import ( + FXHasTenantCourseAccess, + IsAnonymousOrSystemStaff, + IsSystemStaff, +) +from futurex_openedx_extensions.helpers.roles import ( + FXViewRoleInfoMixin, + get_accessible_tenant_ids, + get_usernames_with_access_roles, +) +from futurex_openedx_extensions.helpers.tenants import ( + get_all_tenants_info, + get_excluded_tenant_ids, + get_tenants_info, +) + +default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() + + +@docs('VersionInfoView.get') +class VersionInfoView(APIView): + """View to get the version information""" + permission_classes = [IsSystemStaff] + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use + """ + GET /api/fx/version/v1/info/ + """ + import futurex_openedx_extensions # pylint: disable=import-outside-toplevel + return JsonResponse({ + 'version': futurex_openedx_extensions.__version__, + }) + + +@docs('AccessibleTenantsInfoView.get') +class AccessibleTenantsInfoView(APIView): + """View to get the list of accessible tenants""" + permission_classes = [IsAnonymousOrSystemStaff] + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use + """ + GET /api/fx/accessible/v1/info/?username_or_email= + """ + username_or_email = request.query_params.get('username_or_email') + try: + user = get_user_by_username_or_email(username_or_email) + except ObjectDoesNotExist: + user = None + + if not user: + return JsonResponse({}) + + tenant_ids = get_accessible_tenant_ids(user) + return JsonResponse(get_tenants_info(tenant_ids)) + + +@docs('AccessibleTenantsInfoViewV2.get') +class AccessibleTenantsInfoViewV2(FXViewRoleInfoMixin, APIView): + """View to get the list of accessible tenants version 2""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'accessible_info' + fx_view_description = 'api/fx/accessible/v2/info/: Get accessible tenants' + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use + """ + GET /api/fx/accessible/v1/info/?username_or_email= + """ + username_or_email = request.query_params.get('username_or_email') + try: + user = get_user_by_username_or_email(username_or_email) + except ObjectDoesNotExist: + user = None + + if not user: + return JsonResponse({}) + + tenant_ids = get_accessible_tenant_ids(user) + return JsonResponse(get_tenants_info(tenant_ids)) + + +@docs('DataExportManagementView.list') +@docs('DataExportManagementView.partial_update') +@docs('DataExportManagementView.retrieve') +class DataExportManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors + """View to list and retrieve data export tasks.""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + serializer_class = serializers.DataExportTaskSerializer + pagination_class = DefaultPagination + fx_view_name = 'exported_files_data' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_default_read_write_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_allowed_write_methods = ['PATCH'] + fx_view_description = 'api/fx/export/v1/tasks/: Data Export Task Management APIs.' + http_method_names = ['get', 'patch'] + filter_backends = [DjangoFilterBackend, DefaultOrderingFilter, DefaultSearchFilter] + filterset_fields = ['related_id', 'view_name'] + ordering = ['-id'] + search_fields = ['filename', 'notes'] + + def get_queryset(self) -> QuerySet: + """Get the list of user tasks.""" + return DataExportTask.objects.filter( + user=self.request.user, + tenant__id__in=self.fx_permission_info['view_allowed_tenant_ids_any_access'] + ) + + def get_object(self) -> DataExportTask: + """Override to ensure that the user can only retrieve their own tasks.""" + task_id = self.kwargs.get('pk') # Use 'pk' for the default lookup + task = get_object_or_404(DataExportTask, id=task_id, user=self.request.user) + return task + + +@docs('ExcludedTenantsView.get') +class ExcludedTenantsView(APIView): + """View to get the list of excluded tenants""" + authentication_classes = default_auth_classes + permission_classes = [IsSystemStaff] + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylint: disable=no-self-use + """Get the list of excluded tenants""" + return JsonResponse(get_excluded_tenant_ids()) + + +@docs('TenantInfoView.get') +class TenantInfoView(FXViewRoleInfoMixin, APIView): + """View to get the list of excluded tenants""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'tenant_info' + fx_default_read_only_roles = COURSE_ACCESS_ROLES_SUPPORTED_READ.copy() + fx_view_description = 'api/fx/tenants/v1/info//: tenant basic information' + + def get( + self, request: Any, tenant_id: str, *args: Any, **kwargs: Any, + ) -> JsonResponse | Response: + """Get the tenant's information by tenant ID""" + if int(tenant_id) not in self.request.fx_permission_info['view_allowed_tenant_ids_any_access']: + return Response( + error_details_to_dictionary(reason='You do not have access to this tenant'), + status=http_status.HTTP_403_FORBIDDEN, + ) + + result = {'tenant_id': int(tenant_id)} + result.update(get_all_tenants_info()['info'].get(int(tenant_id))) + return JsonResponse(result) + + +class ClickhouseQueryView(FXViewRoleInfoMixin, APIView): + """View to get the Clickhouse query""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'clickhouse_query_fetcher' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/query/v1//: Get result of the related clickhouse query' + + @staticmethod + def get_page_url_with_page(url: str, new_page_no: int | None) -> str | None: + """ + Get the URL with the new page number + + :param url: The URL + :type url: str + :param new_page_no: The new page number + :type new_page_no: int | None + :return: The URL with the new page number + :rtype: str | None + """ + if new_page_no is None: + return None + + url_parts = urlsplit(url) + query_params = parse_qs(url_parts.query) + + page_size = query_params.get(DefaultPagination.page_size_query_param, None) + if page_size: + del query_params[DefaultPagination.page_size_query_param] + + if 'page' in query_params: + del query_params['page'] + + if page_size: + query_params[DefaultPagination.page_size_query_param] = page_size + query_params['page'] = [str(new_page_no)] + + new_query_string = urlencode(query_params, doseq=True) + + new_url_parts = (url_parts.scheme, url_parts.netloc, url_parts.path, new_query_string, url_parts.fragment) + new_full_url = urlunsplit(new_url_parts) + return new_full_url + + @staticmethod + def pop_out_page_params(params: Dict[str, str], paginated: bool) -> tuple[int | None, int]: + """ + Pop out the page and page size parameters, and return them as integers in the result. Always return the page + as None if not paginated + + :param params: The parameters + :type params: Dict[str, str] + :param paginated: Whether the query is paginated + :type paginated: bool + :return: The page and page size parameters + :rtype: tuple[int | None, int] + """ + page_str: str | None = params.pop('page', None) + page_size_str: str = params.pop( + DefaultPagination.page_size_query_param, '' + ) or str(DefaultPagination.page_size) + + if not paginated: + page = None + else: + page = int(page_str) if page_str is not None else page_str + page = 1 if page is None else page + + return page, int(page_size_str) + + def get(self, request: Any, scope: str, slug: str) -> JsonResponse | Response: + """ + GET /api/fx/query/v1/// + + :param request: The request object + :type request: Request + :param scope: The scope of the query (course, tenant, user) + :type scope: str + :param slug: The slug of the query + :type slug: str + """ + clickhouse_query = ClickhouseQuery.get_query_record(scope, 'v1', slug) + if not clickhouse_query: + return Response( + error_details_to_dictionary(reason=f'Query not found {scope}.v1.{slug}'), + status=http_status.HTTP_404_NOT_FOUND + ) + + if not clickhouse_query.enabled: + return Response( + error_details_to_dictionary(reason=f'Query is disabled {scope}.v1.{slug}'), + status=http_status.HTTP_400_BAD_REQUEST + ) + + params = request.query_params.dict() + self.get_page_url_with_page(request.build_absolute_uri(), 9) + + page, page_size = self.pop_out_page_params(params, clickhouse_query.paginated) + + orgs = request.fx_permission_info['view_allowed_any_access_orgs'].copy() + params[CLICKHOUSE_FX_BUILTIN_ORG_IN_TENANTS] = orgs + if CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS in clickhouse_query.query: + params[CLICKHOUSE_FX_BUILTIN_CA_USERS_OF_TENANTS] = get_usernames_with_access_roles(orgs) + + error_response = None + try: + clickhouse_query.fix_param_types(params) + + with ch.get_client() as clickhouse_client: + records_count, next_page, result = ch.execute_query( + clickhouse_client, + query=clickhouse_query.query, + parameters=params, + page=page, + page_size=page_size, + ) + + except EmptyPage as exc: + error_response = Response( + error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_404_NOT_FOUND + ) + except (ch.ClickhouseClientNotConfiguredError, ch.ClickhouseClientConnectionError) as exc: + error_response = Response( + error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_503_SERVICE_UNAVAILABLE + ) + except (ch.ClickhouseBaseError, ValueError) as exc: + error_response = Response( + error_details_to_dictionary(reason=str(exc)), status=http_status.HTTP_400_BAD_REQUEST + ) + except ValidationError as exc: + error_response = Response( + error_details_to_dictionary(reason=exc.message), status=http_status.HTTP_400_BAD_REQUEST + ) + + if error_response: + return error_response + + if clickhouse_query.paginated: + return JsonResponse({ + 'count': records_count, + 'next': self.get_page_url_with_page(request.build_absolute_uri(), next_page), + 'previous': self.get_page_url_with_page( + request.build_absolute_uri(), + None if page == 1 else page - 1 if page else None, + ), + 'results': ch.result_to_json(result), + }) + + return JsonResponse(ch.result_to_json(result), safe=False) + + +class SetThemePreviewCookieView(APIView): + """View to set theme preview cookie""" + def get(self, request: Any) -> Any: # pylint: disable=no-self-use + """Set theme preview cookie""" + next_url = request.GET.get('next', request.build_absolute_uri()) + if request.COOKIES.get('theme-preview') == 'yes': + return redirect(next_url) + + return render(request, template_name='set_theme_preview.html', context={'next_url': next_url})