Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
C
course-discovery
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
course-discovery
Commits
b2f05bab
Commit
b2f05bab
authored
Jun 16, 2016
by
Clinton Blackburn
Committed by
GitHub
Jun 16, 2016
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #121 from edx/bderusha/download-catalog
Add flattened course_run csv download endpoint
parents
c0ead35d
a0d7543c
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
281 additions
and
17 deletions
+281
-17
course_discovery/apps/api/renderers.py
+54
-0
course_discovery/apps/api/serializers.py
+85
-0
course_discovery/apps/api/v1/tests/test_views/mixins.py
+4
-1
course_discovery/apps/api/v1/tests/test_views/test_catalogs.py
+69
-3
course_discovery/apps/api/v1/views.py
+33
-3
course_discovery/apps/core/forms.py
+1
-0
course_discovery/apps/course_metadata/models.py
+13
-2
course_discovery/apps/course_metadata/tests/factories.py
+5
-5
course_discovery/apps/course_metadata/tests/test_models.py
+16
-3
requirements/base.txt
+1
-0
No files found.
course_discovery/apps/api/renderers.py
View file @
b2f05bab
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'
,
]
course_discovery/apps/api/serializers.py
View file @
b2f05bab
...
...
@@ -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
course_discovery/apps/api/v1/tests/test_views/mixins.py
View file @
b2f05bab
...
...
@@ -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
):
...
...
course_discovery/apps/api/v1/tests/test_views/test_catalogs.py
View file @
b2f05bab
...
...
@@ -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
})
...
...
course_discovery/apps/api/v1/views.py
View file @
b2f05bab
...
...
@@ -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. """
...
...
course_discovery/apps/core/forms.py
View file @
b2f05bab
...
...
@@ -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
)
...
...
course_discovery/apps/course_metadata/models.py
View file @
b2f05bab
...
...
@@ -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
):
...
...
course_discovery/apps/course_metadata/tests/factories.py
View file @
b2f05bab
...
...
@@ -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
()
)
...
...
course_discovery/apps/course_metadata/tests/test_models.py
View file @
b2f05bab
...
...
@@ -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. """
...
...
requirements/base.txt
View file @
b2f05bab
...
...
@@ -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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment