Commit b2f05bab by Clinton Blackburn Committed by GitHub

Merge pull request #121 from edx/bderusha/download-catalog

Add flattened course_run csv download endpoint
parents c0ead35d a0d7543c
from rest_framework_csv.renderers import CSVRenderer
from rest_framework_xml.renderers import XMLRenderer
......@@ -9,3 +10,56 @@ class AffiliateWindowXMLRenderer(XMLRenderer):
"""
item_tag_name = 'product'
root_tag_name = 'merchant'
class CourseRunCSVRenderer(CSVRenderer):
""" CSV renderer for course runs. """
header = [
'key',
'title',
'pacing_type',
'start',
'end',
'enrollment_start',
'enrollment_end',
'announcement',
'full_description',
'short_description',
'marketing_url',
'image.src',
'image.description',
'image.height',
'image.width',
'video.src',
'video.description',
'video.image.src',
'video.image.description',
'video.image.height',
'video.image.width',
'content_language',
'level_type',
'max_effort',
'min_effort',
'subjects',
'expected_learning_items',
'prerequisites',
'owners',
'sponsors',
'seats.audit.type',
'seats.honor.type',
'seats.professional.type',
'seats.professional.price',
'seats.professional.currency',
'seats.professional.upgrade_deadline',
'seats.verified.type',
'seats.verified.price',
'seats.verified.currency',
'seats.verified.upgrade_deadline',
'seats.credit.type',
'seats.credit.price',
'seats.credit.currency',
'seats.credit.upgrade_deadline',
'seats.credit.credit_provider',
'seats.credit.credit_hours',
'modified',
]
......@@ -245,3 +245,88 @@ class AffiliateWindowSerializer(serializers.ModelSerializer):
def get_category(self, obj): # pylint: disable=unused-argument
return self.CATEGORY
class FlattenedCourseRunWithCourseSerializer(CourseRunSerializer):
seats = serializers.SerializerMethodField()
owners = serializers.SerializerMethodField()
sponsors = serializers.SerializerMethodField()
subjects = serializers.SerializerMethodField()
prerequisites = serializers.SerializerMethodField()
level_type = serializers.SerializerMethodField()
expected_learning_items = serializers.SerializerMethodField()
course_key = serializers.SerializerMethodField()
class Meta(object):
model = CourseRun
fields = (
'key', 'title', 'short_description', 'full_description', 'level_type', 'subjects', 'prerequisites',
'start', 'end', 'enrollment_start', 'enrollment_end', 'announcement', 'seats', 'content_language',
'transcript_languages', 'instructors', 'staff', 'pacing_type', 'min_effort', 'max_effort', 'course_key',
'expected_learning_items', 'image', 'video', 'owners', 'sponsors', 'modified', 'marketing_url',
)
def get_seats(self, obj):
seats = {
'audit': {
'type': ''
},
'honor': {
'type': ''
},
'verified': {
'type': '',
'currency': '',
'price': '',
'upgrade_deadline': '',
},
'professional': {
'type': '',
'currency': '',
'price': '',
'upgrade_deadline': '',
},
'credit': {
'type': [],
'currency': [],
'price': [],
'upgrade_deadline': [],
'credit_provider': [],
'credit_hours': [],
},
}
for seat in obj.seats.all():
for key in seats[seat.type].keys():
if seat.type == 'credit':
seats['credit'][key].append(SeatSerializer(seat).data[key])
else:
seats[seat.type][key] = SeatSerializer(seat).data[key]
for credit_attr in seats['credit'].keys():
seats['credit'][credit_attr] = ','.join([str(e) for e in seats['credit'][credit_attr]])
return seats
def get_owners(self, obj):
return ','.join([owner.key for owner in obj.course.owners.all()])
def get_sponsors(self, obj):
return ','.join([sponsor.key for sponsor in obj.course.sponsors.all()])
def get_subjects(self, obj):
return ','.join([subject.name for subject in obj.course.subjects.all()])
def get_prerequisites(self, obj):
return ','.join([prerequisite.name for prerequisite in obj.course.prerequisites.all()])
def get_expected_learning_items(self, obj):
return ','.join(
[expected_learning_item.value for expected_learning_item in obj.course.expected_learning_items.all()]
)
def get_level_type(self, obj):
return obj.course.level_type
def get_course_key(self, obj):
return obj.course.key
......@@ -7,7 +7,7 @@ from django.conf import settings
from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseSerializer, CourseSerializerExcludingClosedRuns
CatalogSerializer, CourseSerializer, CourseSerializerExcludingClosedRuns, FlattenedCourseRunWithCourseSerializer
)
......@@ -32,6 +32,9 @@ class SerializationMixin(object):
def serialize_catalog_course(self, course, many=False, format=None):
return self._serialize_object(CourseSerializerExcludingClosedRuns, course, many, format)
def serialize_catalog_flat_course_run(self, course_run, many=False, format=None):
return self._serialize_object(FlattenedCourseRunWithCourseSerializer, course_run, many, format)
class OAuth2Mixin(object):
def generate_oauth2_token_header(self, user):
......
......@@ -15,7 +15,7 @@ from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory
User = get_user_model()
......@@ -148,13 +148,79 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi
def test_contains(self):
""" Verify the endpoint returns a filtered list of courses contained in the catalog. """
course_key = self.course.key
qs = urllib.parse.urlencode({'course_id': course_key})
url = '{}?{}'.format(reverse('api:v1:catalog-contains', kwargs={'id': self.catalog.id}), qs)
query_string = urllib.parse.urlencode({'course_id': course_key})
url = '{base_url}?{query_string}'.format(
base_url=reverse('api:v1:catalog-contains', kwargs={'id': self.catalog.id}),
query_string=query_string
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {'courses': {course_key: True}})
def test_csv(self):
SeatFactory(type='audit', course_run=self.course_run)
SeatFactory(type='verified', course_run=self.course_run)
SeatFactory(type='credit', course_run=self.course_run, credit_provider='ASU', credit_hours=9)
SeatFactory(type='credit', course_run=self.course_run, credit_provider='Hogwarts', credit_hours=4)
url = reverse('api:v1:catalog-csv', kwargs={'id': self.catalog.id})
response = self.client.get(url)
course_run = self.serialize_catalog_flat_course_run(self.course_run)
course_run_csv = ','.join([
course_run['key'],
course_run['title'],
course_run['pacing_type'],
course_run['start'],
course_run['end'],
course_run['enrollment_start'],
course_run['enrollment_end'],
course_run['announcement'],
course_run['full_description'],
course_run['short_description'],
course_run['marketing_url'],
course_run['image']['src'],
course_run['image']['description'],
str(course_run['image']['height']),
str(course_run['image']['width']),
course_run['video']['src'],
course_run['video']['description'],
course_run['video']['image']['src'],
course_run['video']['image']['description'],
str(course_run['video']['image']['height']),
str(course_run['video']['image']['width']),
course_run['content_language'],
str(course_run['level_type']),
str(course_run['max_effort']),
str(course_run['min_effort']),
course_run['subjects'],
course_run['expected_learning_items'],
course_run['prerequisites'],
course_run['owners'],
course_run['sponsors'],
course_run['seats']['audit']['type'],
course_run['seats']['honor']['type'],
course_run['seats']['professional']['type'],
str(course_run['seats']['professional']['price']),
course_run['seats']['professional']['currency'],
course_run['seats']['professional']['upgrade_deadline'],
course_run['seats']['verified']['type'],
str(course_run['seats']['verified']['price']),
course_run['seats']['verified']['currency'],
course_run['seats']['verified']['upgrade_deadline'],
'"{}"'.format(course_run['seats']['credit']['type']),
'"{}"'.format(str(course_run['seats']['credit']['price'])),
'"{}"'.format(course_run['seats']['credit']['currency']),
'"{}"'.format(course_run['seats']['credit']['upgrade_deadline']),
'"{}"'.format(course_run['seats']['credit']['credit_provider']),
'"{}"'.format(course_run['seats']['credit']['credit_hours']),
course_run['modified'],
])
self.assertEqual(response.status_code, 200)
self.assertIn(course_run_csv, response.content.decode('utf-8'))
def test_get(self):
""" Verify the endpoint returns the details for a single catalog. """
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
......
......@@ -3,15 +3,16 @@ import logging
import os
from io import StringIO
import pytz
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Lower
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from dry_rest_permissions.generics import DRYPermissions
from edx_rest_framework_extensions.permissions import IsSuperuser
import pytz
from rest_framework import status, viewsets
from rest_framework.decorators import detail_route, list_route
from rest_framework.exceptions import PermissionDenied
......@@ -19,10 +20,11 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from course_discovery.apps.api.filters import PermissionsFilter
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, CourseRunCSVRenderer
from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseSerializer, CourseRunSerializer, ContainedCoursesSerializer,
CourseSerializerExcludingClosedRuns, AffiliateWindowSerializer, ContainedCourseRunsSerializer
CourseSerializerExcludingClosedRuns, AffiliateWindowSerializer, ContainedCourseRunsSerializer,
FlattenedCourseRunWithCourseSerializer
)
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.core.utils import SearchQuerySetWrapper
......@@ -137,6 +139,34 @@ class CatalogViewSet(viewsets.ModelViewSet):
serializer = ContainedCoursesSerializer(instance)
return Response(serializer.data)
@detail_route()
def csv(self, request, id=None): # pylint: disable=redefined-builtin,unused-argument
"""
Retrieve a CSV containing the course runs contained within this catalog.
Only active course runs are returned. A course run is considered active if it is currently
open for enrollment, or will be open for enrollment in the future.
---
serializer: FlattenedCourseRunWithCourseSerializer
"""
catalog = self.get_object()
courses = catalog.courses().active()
course_runs = []
for course in courses:
active_course_runs = course.active_course_runs
for acr in active_course_runs:
course_runs.append(acr)
serializer = FlattenedCourseRunWithCourseSerializer(course_runs, many=True, context={'request': request})
data = CourseRunCSVRenderer().render(serializer.data)
response = HttpResponse(data, content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="catalog_{id}_{date}.csv"'.format(
id=id, date=datetime.datetime.utcnow().strftime('%Y-%m-%d-%H-%M')
)
return response
class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" Course resource. """
......
......@@ -19,6 +19,7 @@ class UserThrottleRateForm(forms.ModelForm):
int(num) # Only evaluated for the (possible) side effect of a ValueError
period_choices = ('second', 'minute', 'hour', 'day')
if period not in period_choices:
# pylint: disable=no-member
# Translators: 'period_choices' is a list of possible values, like ('second', 'minute', 'hour')
error_msg = _("period must be one of {period_choices}.").format(period_choices=period_choices)
raise forms.ValidationError(error_msg)
......
......@@ -3,6 +3,7 @@ import logging
import pytz
from django.db import models
from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from haystack.query import SearchQuerySet
......@@ -147,12 +148,22 @@ class Course(TimeStampedModel):
@property
def active_course_runs(self):
""" Returns course runs currently open for enrollment, or opening in the future.
""" Returns course runs that have not yet ended and meet the following enrollment criteria:
- Open for enrollment
- OR will be open for enrollment in the future
- OR have no specified enrollment close date (e.g. self-paced courses)
Returns:
QuerySet
"""
return self.course_runs.filter(enrollment_end__gt=datetime.datetime.now(pytz.UTC))
now = datetime.datetime.now(pytz.UTC)
return self.course_runs.filter(
Q(end__gt=now) &
(
Q(enrollment_end__gt=now) |
Q(enrollment_end__isnull=True)
)
)
@classmethod
def search(cls, query):
......
......@@ -21,11 +21,11 @@ class FuzzyURL(BaseFuzzyAttribute):
tld = FuzzyChoice(('com', 'net', 'org', 'biz', 'pizza', 'coffee', 'diamonds', 'fail', 'win', 'wtf',))
resource = FuzzyText()
return "{protocol}://{subdomain}.{domain}.{tld}/{resource}".format(
protocol=protocol,
subdomain=subdomain,
domain=domain,
tld=tld,
resource=resource
protocol=protocol.fuzz(),
subdomain=subdomain.fuzz(),
domain=domain.fuzz(),
tld=tld.fuzz(),
resource=resource.fuzz()
)
......
......@@ -51,13 +51,26 @@ class CourseTests(TestCase):
# pylint: disable=no-member
self.assertListEqual(list(self.course.active_course_runs), [])
# Create course with end date in future and enrollment_end in past.
end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2)
enrollment_end = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
factories.CourseRunFactory(course=self.course, enrollment_end=enrollment_end)
factories.CourseRunFactory(course=self.course, end=end, enrollment_end=enrollment_end)
# Create course with end date in past and no enrollment_end.
end = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=2)
factories.CourseRunFactory(course=self.course, end=end, enrollment_end=None)
self.assertListEqual(list(self.course.active_course_runs), [])
# Create course with end date in future and enrollment_end in future.
end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=2)
enrollment_end = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
active = factories.CourseRunFactory(course=self.course, enrollment_end=enrollment_end)
self.assertListEqual(list(self.course.active_course_runs), [active])
active_enrollment_end = factories.CourseRunFactory(course=self.course, end=end, enrollment_end=enrollment_end)
# Create course with end date in future and no enrollment_end.
active_no_enrollment_end = factories.CourseRunFactory(course=self.course, end=end, enrollment_end=None)
self.assertEqual(set(self.course.active_course_runs), {active_enrollment_end, active_no_enrollment_end})
def test_search(self):
""" Verify the method returns a filtered queryset of courses. """
......
......@@ -7,6 +7,7 @@ django-simple-history==1.8.1
django-sortedm2m==1.3.0
django-waffle==0.11.1
djangorestframework==3.3.3
djangorestframework-csv==1.4.1
djangorestframework-jwt==1.8.0
djangorestframework-xml==1.3.0
django-rest-swagger[reST]==0.3.7
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment