"""Figures views """ from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required, user_passes_test import django.contrib.sites.shortcuts from django.contrib.sites.models import Site from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.views.decorators.csrf import ensure_csrf_cookie from rest_framework import viewsets from rest_framework.authentication import ( BasicAuthentication, SessionAuthentication, TokenAuthentication, ) from rest_framework.decorators import detail_route, list_route from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.filters import ( DjangoFilterBackend, SearchFilter, OrderingFilter ) from rest_framework.response import Response from rest_framework.views import APIView from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey # Directly including edx-platform objects for early development # Follow-on, we'll likely consolidate edx-platform model imports to an adapter from openedx.core.djangoapps.content.course_overviews.models import CourseOverview # noqa pylint: disable=import-error from student.models import CourseEnrollment # pylint: disable=import-error from figures.filters import ( CourseDailyMetricsFilter, CourseEnrollmentFilter, CourseMauMetricsFilter, CourseOverviewFilter, SiteDailyMetricsFilter, SiteFilterSet, SiteMauMetricsFilter, UserFilterSet, ) from figures.models import ( CourseDailyMetrics, CourseMauMetrics, SiteDailyMetrics, SiteMauMetrics, ) from figures.serializers import ( CourseDailyMetricsSerializer, CourseDetailsSerializer, CourseEnrollmentSerializer, CourseIndexSerializer, CourseMauMetricsSerializer, CourseMauLiveMetricsSerializer, GeneralCourseDataSerializer, LearnerDetailsSerializer, SiteDailyMetricsSerializer, SiteMauMetricsSerializer, SiteMauLiveMetricsSerializer, SiteSerializer, UserIndexSerializer, GeneralUserDataSerializer, get_course_history_metric, ) from figures import metrics from figures.pagination import ( FiguresLimitOffsetPagination, FiguresKiloPagination, ) import figures.permissions import figures.helpers import figures.sites from figures.mau import ( retrieve_live_course_mau_data, retrieve_live_site_mau_data, ) UNAUTHORIZED_USER_REDIRECT_URL = '/' # # UI Template rendering views # @ensure_csrf_cookie @login_required @user_passes_test(lambda u: u.is_active, login_url=UNAUTHORIZED_USER_REDIRECT_URL, redirect_field_name=None) def figures_home(request): '''Renders the JavaScript SPA dashboard TODO: Should we make this a view class? ''' # We probably want to roll this into a decorator if not figures.permissions.is_site_admin_user(request): return HttpResponseRedirect('/') # Placeholder context vars just to illustrate returning API hosts to the # client. This one uses a protocol relative url context = { 'figures_api_url': '//api.example.com', } return render(request, 'figures/index.html', context) # # Mixins for API views # class CommonAuthMixin(object): '''Provides a common authorization base for the Figures API views TODO: Consider moving this to figures.permissions ''' authentication_classes = ( BasicAuthentication, SessionAuthentication, TokenAuthentication, ) permission_classes = ( IsAuthenticated, figures.permissions.IsSiteAdminUser, ) class StaffUserOnDefaultSiteAuthMixin(object): '''Provides a common authorization base for the Figures API views TODO: Consider moving this to figures.permissions ''' authentication_classes = ( BasicAuthentication, SessionAuthentication, TokenAuthentication, ) permission_classes = ( IsAuthenticated, figures.permissions.IsStaffUserOnDefaultSite, ) # # Views for data in edX platform # # @view_auth_classes(is_authenticated=True) class CoursesIndexViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): '''Provides a list of courses with abbreviated details Uses figures.filters.CourseOverviewFilter to select subsets of CourseOverview objects We want to be able to filter on - org: exact and search - name: exact and search - description search - enrollment start - enrollment end - start - end ''' model = CourseOverview pagination_class = FiguresLimitOffsetPagination serializer_class = CourseIndexSerializer filter_backends = (DjangoFilterBackend, ) filter_class = CourseOverviewFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_courses_for_site(site) return queryset class UserIndexViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): '''Provides a list of users with abbreviated details Uses figures.filters.UserFilter to select subsets of User objects ''' model = get_user_model() pagination_class = FiguresLimitOffsetPagination serializer_class = UserIndexSerializer filter_backends = (DjangoFilterBackend, ) filter_class = UserFilterSet def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_users_for_site(site) return queryset class CourseEnrollmentViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): model = CourseEnrollment pagination_class = FiguresLimitOffsetPagination serializer_class = CourseEnrollmentSerializer filter_backends = (DjangoFilterBackend, ) filter_class = CourseEnrollmentFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_course_enrollments_for_site(site) return queryset # # Views for Figures models # class CourseDailyMetricsViewSet(CommonAuthMixin, viewsets.ModelViewSet): model = CourseDailyMetrics # queryset = CourseDailyMetrics.objects.all() pagination_class = FiguresLimitOffsetPagination serializer_class = CourseDailyMetricsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = CourseDailyMetricsFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = CourseDailyMetrics.objects.filter(site=site) return queryset class SiteDailyMetricsViewSet(CommonAuthMixin, viewsets.ModelViewSet): model = SiteDailyMetrics pagination_class = FiguresLimitOffsetPagination serializer_class = SiteDailyMetricsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = SiteDailyMetricsFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = SiteDailyMetrics.objects.filter(site=site) return queryset # # Views for the front end # class GeneralSiteMetricsView(CommonAuthMixin, APIView): """Viewset intended for Figures Web UI DEPRECATED. This view is deprecated TODO: Determine when we remove this class Initial version assumes a single site. Multi-tenancy will add a Site foreign key to the SiteDailyMetrics model and list the most recent data for all sites (or filtered sites) """ pagination_class = FiguresLimitOffsetPagination @property def metrics_method(self): ''' A bit of a hack until we refactor the metrics methods into classes. This lets us override this functionality, in particular to simplify testing ''' return metrics.get_monthly_site_metrics def get(self, request, format=None): # pylint: disable=redefined-builtin ''' Does not yet support multi-tenancy ''' site = django.contrib.sites.shortcuts.get_current_site(request) date_for = request.query_params.get('date_for') data = self.metrics_method(site=site, date_for=date_for) if not data: data = { 'error': 'no metrics data available', } return Response(data) class GeneralCourseDataViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): """Viewset intended for Figures Web UI """ model = CourseOverview # The "kilo paginator" is a tempoarary hack to return all course to not # have to change the front end until Figures "Level 2" pagination_class = FiguresKiloPagination serializer_class = GeneralCourseDataSerializer filter_backends = (SearchFilter, DjangoFilterBackend, OrderingFilter) filter_class = CourseOverviewFilter search_fields = ['display_name', 'id'] ordering_fields = ['display_name', 'self_paced', 'date_joined'] def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_courses_for_site(site) return queryset def retrieve(self, request, *args, **kwargs): course_id_str = kwargs.get('pk', '') course_key = CourseKey.from_string(course_id_str.replace(' ', '+')) site = django.contrib.sites.shortcuts.get_current_site(request) if figures.helpers.is_multisite(): if site != figures.sites.get_site_for_course(course_key): # Raising NotFound instead of PermissionDenied raise NotFound() course_overview = get_object_or_404(CourseOverview, pk=course_key) return Response(GeneralCourseDataSerializer(course_overview).data) class CourseDetailsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): ''' ''' model = CourseOverview # The "kilo paginator" is a tempoarary hack to return all course to not # have to change the front end until Figures "Level 2" pagination_class = FiguresKiloPagination serializer_class = CourseDetailsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = CourseOverviewFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_courses_for_site(site) return queryset def retrieve(self, request, *args, **kwargs): # NOTE: Duplicating code in GeneralCourseDataViewSet. Candidate to dry up # Make it a decorator course_id_str = kwargs.get('pk', '') course_key = CourseKey.from_string(course_id_str.replace(' ', '+')) site = django.contrib.sites.shortcuts.get_current_site(request) if figures.helpers.is_multisite(): if site != figures.sites.get_site_for_course(course_key): # Raising NotFound instead of PermissionDenied raise NotFound() course_overview = get_object_or_404(CourseOverview, pk=course_key) return Response(CourseDetailsSerializer(course_overview).data) class GeneralUserDataViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): '''View class to serve general user data to the Figures UI See the serializer class, GeneralUserDataSerializer for the specific fields returned Can filter users for a specific course by providing the 'course_id' query parameter TODO: Make this class and any other User model based viewsets inherit a base. The only difference between them is the serializer ''' model = get_user_model() pagination_class = FiguresKiloPagination serializer_class = GeneralUserDataSerializer filter_backends = (SearchFilter, DjangoFilterBackend, OrderingFilter) filter_class = UserFilterSet search_fields = ['username', 'email', 'profile__name'] ordering_fields = ['username', 'email', 'profile__name', 'is_active', 'date_joined'] def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_users_for_site(site) return queryset class LearnerDetailsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): model = get_user_model() pagination_class = FiguresLimitOffsetPagination serializer_class = LearnerDetailsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = UserFilterSet def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = figures.sites.get_users_for_site(site) return queryset def get_serializer_context(self): context = super(LearnerDetailsViewSet, self).get_serializer_context() context['site'] = django.contrib.sites.shortcuts.get_current_site(self.request) return context class CourseMonthlyMetricsViewSet(CommonAuthMixin, viewsets.ViewSet): """ """ # TODO: Make 'months_back' be a query parameter. # We will also need to either set a limit or paginate history results months_back = 6 def site_course_helper(self, pk): """Hep Improvements: * make this a decorator * Test this with both course id strings and CourseKey objects """ course_id = pk.replace(' ', '+') try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: raise NotFound() site = django.contrib.sites.shortcuts.get_current_site(self.request) if figures.helpers.is_multisite(): if site != figures.sites.get_site_for_course(course_id): raise NotFound() else: get_object_or_404(CourseOverview, pk=course_key) return site, course_id def historic_data(self, site, course_id, func, **_kwargs): date_for = _kwargs.get('date_for', datetime.utcnow().date()) months_back = _kwargs.get('months_back', self.months_back) return get_course_history_metric( site=site, course_id=course_id, func=func, date_for=date_for, months_back=months_back ) def list(self, request): """ Returns site metrics data for current month TODO: NEXT Add query params to get data from previous months TODO: Add paginagation """ site = django.contrib.sites.shortcuts.get_current_site(self.request) course_keys = figures.sites.get_course_keys_for_site(site) date_for = datetime.utcnow().date() month_for = '{}/{}'.format(date_for.month, date_for.year) data = [] for course_key in course_keys: data.append(metrics.get_month_course_metrics(site=site, course_id=str(course_key), month_for=month_for)) return Response(data) def retrieve(self, request, *args, **kwargs): """ TODO: Make sure we have a test to handle invalid or empty course id """ site, course_id = self.site_course_helper(kwargs.get('pk', '')) date_for = datetime.utcnow().date() month_for = '{}/{}'.format(date_for.month, date_for.year) data = metrics.get_month_course_metrics(site=site, course_id=course_id, month_for=month_for) return Response(data) @detail_route() def active_users(self, request, **kwargs): site, course_id = self.site_course_helper(kwargs.get('pk', '')) date_for = datetime.utcnow().date() months_back = 6 active_users = metrics.get_course_mau_history_metrics( site=site, course_id=course_id, date_for=date_for, months_back=months_back, ) data = dict(active_users=active_users) return Response(data) @detail_route() def course_enrollments(self, request, *args, **kwargs): site, course_id = self.site_course_helper(kwargs.get('pk', '')) data = dict(course_enrollments=self.historic_data( request=request, site=site, course_id=course_id, func=metrics.get_course_enrolled_users_for_time_period)) return Response(data) @detail_route() def num_learners_completed(self, request, *args, **kwargs): site, course_id = self.site_course_helper(kwargs.get('pk', '')) data = dict(num_learners_completed=self.historic_data( request=request, site=site, course_id=course_id, func=metrics.get_course_num_learners_completed_for_time_period)) return Response(data) @detail_route() def avg_days_to_complete(self, request, *args, **kwargs): site, course_id = self.site_course_helper(kwargs.get('pk', '')) data = dict(avg_days_to_complete=self.historic_data( request=request, site=site, course_id=course_id, func=metrics.get_course_average_days_to_complete_for_time_period)) return Response(data) @detail_route() def avg_progress(self, request, *args, **kwargs): site, course_id = self.site_course_helper(kwargs.get('pk', '')) data = dict(avg_progress=self.historic_data( request=request, site=site, course_id=course_id, func=metrics.get_course_average_progress_for_time_period)) return Response(data) class SiteMonthlyMetricsViewSet(CommonAuthMixin, viewsets.ViewSet): """Serves sitewide metrics TODO: * Make months_back be a query param. * Create a decorator to do the duplicate work in these methods * Improve test coverage * Create viewsets for `SiteMetricsViewSet`, `UserMetricsViewSet` # `CourseMetricsViewSet` for retrieving live data for each context ## Dev note Does it benefit to make serializers for these calls? Do we want to create a SiteMonthlyMetrics model? If we do, then we can also use django-filter on the model Tradeoff: Additional storage cost to reduced request time Perhaps we make this a server setting """ def list(self, request): """ Returns site metrics data for current month """ site = django.contrib.sites.shortcuts.get_current_site(self.request) data = metrics.get_current_month_site_metrics(site) return Response(data) @list_route() def registered_users(self, request): site = django.contrib.sites.shortcuts.get_current_site(self.request) date_for = datetime.utcnow().date() months_back = 6 registered_users = metrics.get_monthly_history_metric( func=metrics.get_total_site_users_for_time_period, site=site, date_for=date_for, months_back=months_back, ) data = dict(registered_users=registered_users) return Response(data) @list_route() def new_users(self, request): """ TODO: Rename the metrics module function to "new_users" to match this """ site = django.contrib.sites.shortcuts.get_current_site(self.request) date_for = datetime.utcnow().date() months_back = 6 new_users = metrics.get_monthly_history_metric( func=metrics.get_total_site_users_joined_for_time_period, site=site, date_for=date_for, months_back=months_back, ) data = dict(new_users=new_users) return Response(data) @list_route() def course_completions(self, request): site = django.contrib.sites.shortcuts.get_current_site(self.request) date_for = datetime.utcnow().date() months_back = 6 course_completions = metrics.get_monthly_history_metric( func=metrics.get_total_course_completions_for_time_period, site=site, date_for=date_for, months_back=months_back, ) data = dict(course_completions=course_completions) return Response(data) @list_route() def course_enrollments(self, request): site = django.contrib.sites.shortcuts.get_current_site(self.request) date_for = datetime.utcnow().date() months_back = 6 course_enrollments = metrics.get_monthly_history_metric( func=metrics.get_total_enrollments_for_time_period, site=site, date_for=date_for, months_back=months_back, ) data = dict(course_enrollments=course_enrollments) return Response(data) @list_route() def site_courses(self, request): site = django.contrib.sites.shortcuts.get_current_site(self.request) date_for = datetime.utcnow().date() months_back = 6 site_courses = metrics.get_monthly_history_metric( func=metrics.get_total_site_courses_for_time_period, site=site, date_for=date_for, months_back=months_back, ) data = dict(site_courses=site_courses) return Response(data) @list_route() def active_users(self, request): site = django.contrib.sites.shortcuts.get_current_site(self.request) months_back = 6 active_users = metrics.get_site_mau_history_metrics(site=site, months_back=months_back) return Response(dict(active_users=active_users)) # # MAU metrics views # class CourseMauLiveMetricsViewSet(CommonAuthMixin, viewsets.GenericViewSet): serializer_class = CourseMauLiveMetricsSerializer def get_queryset(self): """ Stub method because ViewSet requires one, even though we are not retrieving querysets directly (we use the query in figures.sites) """ pass def retrieve(self, request, *args, **kwargs): course_id_str = kwargs.get('pk', '') course_key = CourseKey.from_string(course_id_str.replace(' ', '+')) site = django.contrib.sites.shortcuts.get_current_site(self.request) if figures.helpers.is_multisite(): if site != figures.sites.get_site_for_course(course_key): # Raising NotFound instead of PermissionDenied raise NotFound() data = retrieve_live_course_mau_data(site, course_key) serializer = self.serializer_class(data) return Response(serializer.data) def list(self, request, *args, **kwargs): site = django.contrib.sites.shortcuts.get_current_site(self.request) course_overviews = figures.sites.get_courses_for_site(site) data = [] for co in course_overviews: data.append(retrieve_live_course_mau_data(site, co.id)) serializer = self.serializer_class(data, many=True) return Response(serializer.data) class SiteMauLiveMetricsViewSet(CommonAuthMixin, viewsets.GenericViewSet): """ Retrieve live MAU site metrics for the site called TODO: Potential future improvement is to display single site if the caller is a site admin for the given site and for all sites (paginated?) if the caller is a host (provider) level user """ serializer_class = SiteMauLiveMetricsSerializer def get_queryset(self): """ Stub method because ViewSet requires one, even though we are not retrieving querysets directly (we use the query in figures.sites) """ pass def list(self, request, *args, **kwargs): """ We use list instead of retrieve because retrieve requires a resource identifier, like a PK """ site = django.contrib.sites.shortcuts.get_current_site(self.request) data = retrieve_live_site_mau_data(site) serializer = self.serializer_class(data) return Response(serializer.data) class CourseMauMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): model = CourseMauMetrics serializer_class = CourseMauMetricsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = CourseMauMetricsFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = CourseMauMetrics.objects.filter(site=site) return queryset class SiteMauMetricsViewSet(CommonAuthMixin, viewsets.ReadOnlyModelViewSet): model = SiteMauMetrics serializer_class = SiteMauMetricsSerializer filter_backends = (DjangoFilterBackend, ) filter_class = SiteMauMetricsFilter def get_queryset(self): site = django.contrib.sites.shortcuts.get_current_site(self.request) queryset = SiteMauMetrics.objects.filter(site=site) return queryset class SiteViewSet(StaffUserOnDefaultSiteAuthMixin, viewsets.ReadOnlyModelViewSet): """Provides API access to the django.contrib.sites.models.Site model Access is restricted to global (Django instance) staff """ model = Site queryset = Site.objects.all() pagination_class = FiguresLimitOffsetPagination serializer_class = SiteSerializer filter_backends = (DjangoFilterBackend, ) filter_class = SiteFilterSet