Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More APIs - Round 4 #33

Merged
merged 5 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""One-line description for README and other doc files."""

__version__ = "0.3.8"
__version__ = "0.4.5"
20 changes: 10 additions & 10 deletions futurex_openedx_extensions/dashboard/details/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@


def get_courses_queryset(
tenant_ids: List, search_text: str = None, visible_filter: bool = True, active_filter: bool = None
tenant_ids: List, search_text: str = None, visible_filter: bool | None = True, active_filter: bool | None = None
) -> QuerySet:
"""
Get the courses queryset for the given tenant IDs and search text.
Expand All @@ -39,10 +39,10 @@ def get_courses_queryset(
:type tenant_ids: List
:param search_text: Search text to filter the courses by
:type search_text: str
:param visible_filter: Whether to only include courses that are visible in the catalog
:type visible_filter: bool
:param active_filter: Whether to only include active courses
:type active_filter: bool
:param visible_filter: Value to filter courses on catalog visibility. None means no filter
:type visible_filter: bool | None
:param active_filter: Value to filter courses on active status. None means no filter
:type active_filter: bool | None
:return: QuerySet of courses
:rtype: QuerySet
"""
Expand Down Expand Up @@ -121,7 +121,7 @@ def get_courses_queryset(


def get_learner_courses_info_queryset(
tenant_ids: List, user_id: int, visible_filter: bool = True, active_filter: bool = None
tenant_ids: List, user_id: int, visible_filter: bool | None = True, active_filter: bool | None = None
) -> QuerySet:
"""
Get the learner's courses queryset for the given user ID. This method assumes a valid user ID.
Expand All @@ -130,10 +130,10 @@ def get_learner_courses_info_queryset(
:type tenant_ids: List
:param user_id: The user ID to get the learner for
:type user_id: int
:param visible_filter: Whether to only count courses that are visible in the catalog
:type visible_filter: bool
:param active_filter: Whether to only count active courses
:type active_filter: bool
:param visible_filter: Value to filter courses on catalog visibility. None means no filter
:type visible_filter: bool | None
:param active_filter: Value to filter courses on active status. None means no filter
:type active_filter: bool | None
:return: QuerySet of learners
:rtype: QuerySet
"""
Expand Down
124 changes: 107 additions & 17 deletions futurex_openedx_extensions/dashboard/details/learners.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"""Learners details collectors"""
from __future__ import annotations

from datetime import timedelta
from typing import List

from common.djangoapps.student.models import CourseAccessRole
from django.contrib.auth import get_user_model
from django.db.models import Count, Exists, OuterRef, Q, Subquery
from django.db.models import BooleanField, Case, Count, Exists, OuterRef, Q, Subquery, Value, When
from django.db.models.query import QuerySet
from django.utils import timezone
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.grades.models import PersistentCourseGrade

from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses, get_has_site_login_queryset
from futurex_openedx_extensions.helpers.tenants import get_course_org_filter_list, get_tenants_sites
Expand Down Expand Up @@ -81,6 +86,46 @@ def get_certificates_count_for_learner_queryset(
)


def get_learners_search_queryset(
search_text: str = None,
superuser_filter: bool | None = False,
staff_filter: bool | None = False,
active_filter: bool | None = True
) -> QuerySet:
"""
Get the learners queryset for the given search text.

:param search_text: Search text to filter the learners by
:type search_text: str
:param superuser_filter: Value to filter superusers. None means no filter
:type superuser_filter: bool
:param staff_filter: Value to filter staff users. None means no filter
:type staff_filter: bool
:param active_filter: Value to filter active users. None means no filter
:type active_filter: bool
:return: QuerySet of learners
:rtype: QuerySet
"""
queryset = get_user_model().objects.all()

if superuser_filter is not None:
queryset = queryset.filter(is_superuser=superuser_filter)
if staff_filter is not None:
queryset = queryset.filter(is_staff=staff_filter)
if active_filter is not None:
queryset = queryset.filter(is_active=active_filter)

search_text = (search_text or '').strip()
if search_text:
queryset = queryset.filter(
Q(username__icontains=search_text) |
Q(email__icontains=search_text) |
Q(profile__name__icontains=search_text)
)

return queryset


def get_learners_queryset(
tenant_ids: List, search_text: str = None, visible_courses_filter: bool = True, active_courses_filter: bool = None
) -> QuerySet:
Expand All @@ -91,28 +136,17 @@ def get_learners_queryset(
:type tenant_ids: List
:param search_text: Search text to filter the learners by
:type search_text: str
:param visible_courses_filter: Whether to only count courses that are visible in the catalog
:param visible_courses_filter: Value to filter courses on catalog visibility. None means no filter
:type visible_courses_filter: bool
:param active_courses_filter: Whether to only count active courses
:param active_courses_filter: Value to filter courses on active status. None means no filter
:type active_courses_filter: bool
:return: QuerySet of learners
:rtype: QuerySet
"""
course_org_filter_list = get_course_org_filter_list(tenant_ids)['course_org_filter_list']
tenant_sites = get_tenants_sites(tenant_ids)

queryset = get_user_model().objects.filter(
is_superuser=False,
is_staff=False,
is_active=True,
)
search_text = (search_text or '').strip()
if search_text:
queryset = queryset.filter(
Q(username__icontains=search_text) |
Q(email__icontains=search_text) |
Q(profile__name__icontains=search_text)
)
queryset = get_learners_search_queryset(search_text)

queryset = queryset.annotate(
courses_count=get_courses_count_for_learner_queryset(
Expand All @@ -135,6 +169,62 @@ def get_learners_queryset(
return queryset


def get_learners_by_course_queryset(course_id: str, search_text: str = None) -> QuerySet:
"""
Get the learners queryset for the given course ID.

:param course_id: The course ID to get the learners for
:type course_id: str
:param search_text: Search text to filter the learners by
:type search_text: str
:return: QuerySet of learners
:rtype: QuerySet
"""
queryset = get_learners_search_queryset(search_text)
queryset = queryset.filter(
courseenrollment__course_id=course_id
).filter(
~Exists(
CourseAccessRole.objects.filter(
user_id=OuterRef('id'),
org=OuterRef('courseenrollment__course__org')
)
)
).annotate(
certificate_available=Exists(
GeneratedCertificate.objects.filter(
user_id=OuterRef('id'),
course_id=course_id,
status='downloadable'
)
)
).annotate(
course_score=Subquery(
PersistentCourseGrade.objects.filter(
user_id=OuterRef('id'),
course_id=course_id
).values('percent_grade')[:1]
)
).annotate(
active_in_course=Case(
When(
Exists(
StudentModule.objects.filter(
student_id=OuterRef('id'),
course_id=course_id,
modified__gte=timezone.now() - timedelta(days=30)
)
),
then=Value(True),
),
default=Value(False),
output_field=BooleanField(),
)
).select_related('profile').order_by('id')

return queryset


def get_learner_info_queryset(
tenant_ids: List, user_id: int, visible_courses_filter: bool = True, active_courses_filter: bool = None
) -> QuerySet:
Expand All @@ -145,9 +235,9 @@ def get_learner_info_queryset(
:type tenant_ids: List
:param user_id: The user ID to get the learner for
:type user_id: int
:param visible_courses_filter: Whether to only count courses that are visible in the catalog
:param visible_courses_filter: Value to filter courses on catalog visibility. None means no filter
:type visible_courses_filter: bool
:param active_courses_filter: Whether to only count active courses
:param active_courses_filter: Value to filter courses on active status. None means no filter
:type active_courses_filter: bool
:return: QuerySet of learners
:rtype: QuerySet
Expand Down
48 changes: 33 additions & 15 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from futurex_openedx_extensions.helpers.tenants import get_tenants_by_org


class LearnerDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner details."""
class LearnerBasicDetailsSerializer(serializers.ModelSerializer):
"""Serializer for learner's basic details."""
user_id = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
alternative_full_name = serializers.SerializerMethodField()
Expand All @@ -27,8 +27,6 @@ class LearnerDetailsSerializer(serializers.ModelSerializer):
gender_display = serializers.SerializerMethodField()
date_joined = serializers.DateTimeField()
last_login = serializers.DateTimeField()
enrolled_courses_count = serializers.SerializerMethodField()
certificates_count = serializers.SerializerMethodField()

class Meta:
model = get_user_model()
Expand All @@ -44,8 +42,6 @@ class Meta:
"gender_display",
"date_joined",
"last_login",
"enrolled_courses_count",
"certificates_count",
]

@staticmethod
Expand Down Expand Up @@ -110,6 +106,23 @@ def get_gender_display(self, obj):
"""Return readable text for gender"""
return self._get_profile_field(obj, "gender_display")

def get_year_of_birth(self, obj):
"""Return year of birth."""
return self._get_profile_field(obj, "year_of_birth")


class LearnerDetailsSerializer(LearnerBasicDetailsSerializer):
"""Serializer for learner details."""
enrolled_courses_count = serializers.SerializerMethodField()
certificates_count = serializers.SerializerMethodField()

class Meta:
model = get_user_model()
fields = LearnerBasicDetailsSerializer.Meta.fields + [
"enrolled_courses_count",
"certificates_count",
]

def get_certificates_count(self, obj): # pylint: disable=no-self-use
"""Return certificates count."""
return obj.certificates_count
Expand All @@ -118,9 +131,20 @@ def get_enrolled_courses_count(self, obj): # pylint: disable=no-self-use
"""Return enrolled courses count."""
return obj.courses_count

def get_year_of_birth(self, obj):
"""Return year of birth."""
return self._get_profile_field(obj, "year_of_birth")

class LearnerDetailsForCourseSerializer(LearnerBasicDetailsSerializer):
"""Serializer for learner details for a course."""
certificate_available = serializers.BooleanField()
course_score = serializers.DecimalField(max_digits=5, decimal_places=2)
active_in_course = serializers.BooleanField()

class Meta:
model = get_user_model()
fields = LearnerBasicDetailsSerializer.Meta.fields + [
"certificate_available",
"course_score",
"active_in_course",
]


class LearnerDetailsExtendedSerializer(LearnerDetailsSerializer):
Expand Down Expand Up @@ -190,7 +214,6 @@ class CourseDetailsBaseSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
org = serializers.CharField()
tenant_ids = serializers.SerializerMethodField()
author_name = serializers.SerializerMethodField()

class Meta:
model = CourseOverview
Expand All @@ -206,7 +229,6 @@ class Meta:
"image_url",
"org",
"tenant_ids",
"author_name",
]

def get_status(self, obj): # pylint: disable=no-self-use
Expand Down Expand Up @@ -245,10 +267,6 @@ def get_end_date(self, obj): # pylint: disable=no-self-use
"""Return the end date."""
return obj.end

def get_author_name(self, obj): # pylint: disable=unused-argument,no-self-use
"""Return the author name."""
return None


class CourseDetailsSerializer(CourseDetailsBaseSerializer):
"""Serializer for course details."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ def plugin_settings(settings):
"FX_CACHE_TIMEOUT_TENANTS_INFO",
60 * 60 * 2, # 2 hours
)

settings.FX_RATE_LIMIT_ANONYMOUS_DATA_RETRIEVE = getattr(
settings,
"FX_RATE_LIMIT_ANONYMOUS_DATA_RETRIEVE",
"1/minute",
)

if settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"].get("fx_anonymous_data_retrieve") is None:
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["fx_anonymous_data_retrieve"] = "5/hour"
4 changes: 2 additions & 2 deletions futurex_openedx_extensions/dashboard/statistics/courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ def get_courses_count_by_status(

:param tenant_ids: List of tenant IDs to get the count for
:type tenant_ids: List[int]
:param visible_filter: Whether to only count courses that are visible in the catalog
:param visible_filter: Value to filter courses on catalog visibility. None means no filter
:type visible_filter: bool
:param active_filter: Whether to only count active courses (according to dates)
:param active_filter: Value to filter courses on active status. None means no filter (according to dates)
:type active_filter: bool
:return: QuerySet of courses count per organization and status
:rtype: QuerySet
Expand Down
5 changes: 5 additions & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
from django.urls import re_path

from futurex_openedx_extensions.dashboard import views
from futurex_openedx_extensions.helpers.constants import COURSE_ID_REGX

app_name = 'fx_dashboard'

urlpatterns = [
re_path(r'^api/fx/accessible/v1/info/$', views.AccessibleTenantsInfoView.as_view(), name='accessible-info'),
re_path(r'^api/fx/courses/v1/courses/$', views.CoursesView.as_view(), name='courses'),
re_path(r'^api/fx/learners/v1/learners/$', views.LearnersView.as_view(), name='learners'),
re_path(
fr'^api/fx/learners/v1/learners/{COURSE_ID_REGX}/$',
views.LearnersDetailsForCourseView.as_view(), name='learners-course'),
re_path(
r'^api/fx/learners/v1/learner/' + settings.USERNAME_PATTERN + '/$',
views.LearnerInfoView.as_view(),
Expand Down
Loading