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
a0d7543c
Commit
a0d7543c
authored
Jun 03, 2016
by
Bill DeRusha
Committed by
Clinton Blackburn
Jun 16, 2016
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add flattened course_run csv download endpoint
parent
c0ead35d
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 @
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'
,
]
course_discovery/apps/api/serializers.py
View file @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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 @
a0d7543c
...
...
@@ -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