Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-proctoring
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
OpenEdx
edx-proctoring
Commits
17f93b7d
Commit
17f93b7d
authored
Jun 26, 2015
by
Afzal Wali
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Added Serializers.
Removed Query Parameters and added them to the URL regex.
parent
cdc4d215
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
86 additions
and
42 deletions
+86
-42
edx_proctoring/api.py
+29
-6
edx_proctoring/exceptions.py
+2
-2
edx_proctoring/serializers.py
+12
-0
edx_proctoring/tests/test_api.py
+5
-5
edx_proctoring/urls.py
+14
-3
edx_proctoring/views.py
+22
-26
settings.py
+2
-0
No files found.
edx_proctoring/api.py
View file @
17f93b7d
...
@@ -9,11 +9,12 @@ API which is in the views.py file, per edX coding standards
...
@@ -9,11 +9,12 @@ API which is in the views.py file, per edX coding standards
import
pytz
import
pytz
from
datetime
import
datetime
from
datetime
import
datetime
from
edx_proctoring.exceptions
import
(
from
edx_proctoring.exceptions
import
(
ProctoredExamAlreadyExist
,
ProctoredExamNotFoundException
,
StudentExamAttemptAlreadyExist
Exception
ProctoredExamAlreadyExist
s
,
ProctoredExamNotFoundException
,
StudentExamAttemptAlreadyExists
Exception
)
)
from
edx_proctoring.models
import
(
from
edx_proctoring.models
import
(
ProctoredExam
,
ProctoredExamStudentAllowance
,
ProctoredExamStudentAttempt
ProctoredExam
,
ProctoredExamStudentAllowance
,
ProctoredExamStudentAttempt
)
)
from
edx_proctoring.serializers
import
ProctoredExamSerializer
def
create_exam
(
course_id
,
content_id
,
exam_name
,
time_limit_mins
,
def
create_exam
(
course_id
,
content_id
,
exam_name
,
time_limit_mins
,
...
@@ -25,7 +26,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
...
@@ -25,7 +26,7 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins,
Returns: id (PK)
Returns: id (PK)
"""
"""
if
ProctoredExam
.
get_exam_by_content_id
(
course_id
,
content_id
)
is
not
None
:
if
ProctoredExam
.
get_exam_by_content_id
(
course_id
,
content_id
)
is
not
None
:
raise
ProctoredExamAlreadyExist
raise
ProctoredExamAlreadyExist
s
proctored_exam
=
ProctoredExam
.
objects
.
create
(
proctored_exam
=
ProctoredExam
.
objects
.
create
(
course_id
=
course_id
,
course_id
=
course_id
,
...
@@ -70,12 +71,23 @@ def get_exam_by_id(exam_id):
...
@@ -70,12 +71,23 @@ def get_exam_by_id(exam_id):
Looks up exam by the Primary Key. Raises exception if not found.
Looks up exam by the Primary Key. Raises exception if not found.
Returns dictionary version of the Django ORM object
Returns dictionary version of the Django ORM object
e.g.
{
"course_id": "edX/DemoX/Demo_Course",
"content_id": "123",
"external_id": "",
"exam_name": "Midterm",
"time_limit_mins": 90,
"is_proctored": true,
"is_active": true
}
"""
"""
proctored_exam
=
ProctoredExam
.
get_exam_by_id
(
exam_id
)
proctored_exam
=
ProctoredExam
.
get_exam_by_id
(
exam_id
)
if
proctored_exam
is
None
:
if
proctored_exam
is
None
:
raise
ProctoredExamNotFoundException
raise
ProctoredExamNotFoundException
return
proctored_exam
.
__dict__
serialized_exam_object
=
ProctoredExamSerializer
(
proctored_exam
)
return
serialized_exam_object
.
data
def
get_exam_by_content_id
(
course_id
,
content_id
):
def
get_exam_by_content_id
(
course_id
,
content_id
):
...
@@ -83,12 +95,23 @@ def get_exam_by_content_id(course_id, content_id):
...
@@ -83,12 +95,23 @@ def get_exam_by_content_id(course_id, content_id):
Looks up exam by the course_id/content_id pair. Raises exception if not found.
Looks up exam by the course_id/content_id pair. Raises exception if not found.
Returns dictionary version of the Django ORM object
Returns dictionary version of the Django ORM object
e.g.
{
"course_id": "edX/DemoX/Demo_Course",
"content_id": "123",
"external_id": "",
"exam_name": "Midterm",
"time_limit_mins": 90,
"is_proctored": true,
"is_active": true
}
"""
"""
proctored_exam
=
ProctoredExam
.
get_exam_by_content_id
(
course_id
,
content_id
)
proctored_exam
=
ProctoredExam
.
get_exam_by_content_id
(
course_id
,
content_id
)
if
proctored_exam
is
None
:
if
proctored_exam
is
None
:
raise
ProctoredExamNotFoundException
raise
ProctoredExamNotFoundException
return
proctored_exam
.
__dict__
serialized_exam_object
=
ProctoredExamSerializer
(
proctored_exam
)
return
serialized_exam_object
.
data
def
add_allowance_for_user
(
exam_id
,
user_id
,
key
,
value
):
def
add_allowance_for_user
(
exam_id
,
user_id
,
key
,
value
):
...
@@ -116,7 +139,7 @@ def start_exam_attempt(exam_id, user_id, external_id):
...
@@ -116,7 +139,7 @@ def start_exam_attempt(exam_id, user_id, external_id):
"""
"""
exam_attempt_obj
=
ProctoredExamStudentAttempt
.
start_exam_attempt
(
exam_id
,
user_id
,
external_id
)
exam_attempt_obj
=
ProctoredExamStudentAttempt
.
start_exam_attempt
(
exam_id
,
user_id
,
external_id
)
if
exam_attempt_obj
is
None
:
if
exam_attempt_obj
is
None
:
raise
StudentExamAttemptAlreadyExistException
raise
StudentExamAttemptAlreadyExist
s
Exception
else
:
else
:
return
exam_attempt_obj
.
id
return
exam_attempt_obj
.
id
...
@@ -127,7 +150,7 @@ def stop_exam_attempt(exam_id, user_id):
...
@@ -127,7 +150,7 @@ def stop_exam_attempt(exam_id, user_id):
"""
"""
exam_attempt_obj
=
ProctoredExamStudentAttempt
.
get_student_exam_attempt
(
exam_id
,
user_id
)
exam_attempt_obj
=
ProctoredExamStudentAttempt
.
get_student_exam_attempt
(
exam_id
,
user_id
)
if
exam_attempt_obj
is
None
:
if
exam_attempt_obj
is
None
:
raise
StudentExamAttemptAlreadyExistException
raise
StudentExamAttemptAlreadyExist
s
Exception
else
:
else
:
exam_attempt_obj
.
completed_at
=
datetime
.
now
(
pytz
.
UTC
)
exam_attempt_obj
.
completed_at
=
datetime
.
now
(
pytz
.
UTC
)
exam_attempt_obj
.
save
()
exam_attempt_obj
.
save
()
...
...
edx_proctoring/exceptions.py
View file @
17f93b7d
...
@@ -3,7 +3,7 @@ Specialized exceptions for the Notification subsystem
...
@@ -3,7 +3,7 @@ Specialized exceptions for the Notification subsystem
"""
"""
class
ProctoredExamAlreadyExist
(
Exception
):
class
ProctoredExamAlreadyExist
s
(
Exception
):
"""
"""
Generic exception when a look up fails. Since we are abstracting away the backends
Generic exception when a look up fails. Since we are abstracting away the backends
we need to catch any native exceptions and re-throw as a generic exception
we need to catch any native exceptions and re-throw as a generic exception
...
@@ -17,7 +17,7 @@ class ProctoredExamNotFoundException(Exception):
...
@@ -17,7 +17,7 @@ class ProctoredExamNotFoundException(Exception):
"""
"""
class
StudentExamAttemptAlreadyExistException
(
Exception
):
class
StudentExamAttemptAlreadyExist
s
Exception
(
Exception
):
"""
"""
Generic exception when a look up fails. Since we are abstracting away the backends
Generic exception when a look up fails. Since we are abstracting away the backends
we need to catch any native exceptions and re-throw as a generic exception
we need to catch any native exceptions and re-throw as a generic exception
...
...
edx_proctoring/serializers.py
0 → 100644
View file @
17f93b7d
"""Defines serializers used by the Proctoring API."""
from
rest_framework
import
serializers
from
edx_proctoring.models
import
ProctoredExam
class
ProctoredExamSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
ProctoredExam
fields
=
(
"course_id"
,
"content_id"
,
"external_id"
,
"exam_name"
,
"time_limit_mins"
,
"is_proctored"
,
"is_active"
)
edx_proctoring/tests/test_api.py
View file @
17f93b7d
...
@@ -5,8 +5,8 @@ from datetime import datetime
...
@@ -5,8 +5,8 @@ from datetime import datetime
import
pytz
import
pytz
from
edx_proctoring.api
import
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
add_allowance_for_user
,
\
from
edx_proctoring.api
import
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
add_allowance_for_user
,
\
remove_allowance_for_user
,
start_exam_attempt
,
stop_exam_attempt
remove_allowance_for_user
,
start_exam_attempt
,
stop_exam_attempt
from
edx_proctoring.exceptions
import
ProctoredExamAlreadyExist
,
ProctoredExamNotFoundException
,
\
from
edx_proctoring.exceptions
import
ProctoredExamAlreadyExist
s
,
ProctoredExamNotFoundException
,
\
StudentExamAttemptAlreadyExistException
StudentExamAttemptAlreadyExist
s
Exception
from
edx_proctoring.models
import
ProctoredExam
,
ProctoredExamStudentAllowance
,
ProctoredExamStudentAllowanceHistory
,
\
from
edx_proctoring.models
import
ProctoredExam
,
ProctoredExamStudentAllowance
,
ProctoredExamStudentAllowanceHistory
,
\
ProctoredExamStudentAttempt
ProctoredExamStudentAttempt
...
@@ -78,7 +78,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -78,7 +78,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
is_proctored
=
True
,
is_proctored
=
True
,
is_active
=
True
is_active
=
True
)
)
with
self
.
assertRaises
(
ProctoredExamAlreadyExist
):
with
self
.
assertRaises
(
ProctoredExamAlreadyExist
s
):
self
.
_create_proctored_exam
()
self
.
_create_proctored_exam
()
def
test_update_proctored_exam
(
self
):
def
test_update_proctored_exam
(
self
):
...
@@ -178,7 +178,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -178,7 +178,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
def
test_create_student_exam_attempt_entry
(
self
):
def
test_create_student_exam_attempt_entry
(
self
):
proctored_exam_student_attempt
=
self
.
_create_student_exam_attempt_entry
()
proctored_exam_student_attempt
=
self
.
_create_student_exam_attempt_entry
()
with
self
.
assertRaises
(
StudentExamAttemptAlreadyExistException
):
with
self
.
assertRaises
(
StudentExamAttemptAlreadyExist
s
Exception
):
start_exam_attempt
(
proctored_exam_student_attempt
.
proctored_exam
,
self
.
user_id
,
self
.
external_id
)
start_exam_attempt
(
proctored_exam_student_attempt
.
proctored_exam
,
self
.
user_id
,
self
.
external_id
)
def
test_stop_exam_attempt
(
self
):
def
test_stop_exam_attempt
(
self
):
...
@@ -191,5 +191,5 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -191,5 +191,5 @@ class ProctoredExamApiTests(LoggedInTestCase):
def
test_stop_invalid_exam_attempt_raises_exception
(
self
):
def
test_stop_invalid_exam_attempt_raises_exception
(
self
):
proctored_exam
=
self
.
_create_proctored_exam
()
proctored_exam
=
self
.
_create_proctored_exam
()
with
self
.
assertRaises
(
StudentExamAttemptAlreadyExistException
):
with
self
.
assertRaises
(
StudentExamAttemptAlreadyExist
s
Exception
):
stop_exam_attempt
(
proctored_exam
,
self
.
user_id
)
stop_exam_attempt
(
proctored_exam
,
self
.
user_id
)
edx_proctoring/urls.py
View file @
17f93b7d
...
@@ -2,13 +2,24 @@
...
@@ -2,13 +2,24 @@
URL mappings for edX Proctoring Server.
URL mappings for edX Proctoring Server.
"""
"""
from
edx_proctoring
import
views
from
edx_proctoring
import
views
from
django.conf
import
settings
from
django.conf.urls
import
patterns
,
url
,
include
from
django.conf.urls
import
patterns
,
url
,
include
urlpatterns
=
patterns
(
# pylint: disable=invalid-name
urlpatterns
=
patterns
(
# pylint: disable=invalid-name
''
,
''
,
url
(
url
(
r'edx_proctoring/v1/proctored_exam/exam'
,
r'edx_proctoring/v1/proctored_exam/exam$'
,
views
.
ProctoredExamView
.
as_view
(),
name
=
'edx_proctoring.proctored_exam.exam'
),
url
(
r'edx_proctoring/v1/proctored_exam/exam/exam_id/(?P<exam_id>\d+)$'
,
views
.
ProctoredExamView
.
as_view
(),
name
=
'edx_proctoring.proctored_exam.exam'
),
url
(
r'edx_proctoring/v1/proctored_exam/exam/course_id/{}/content_id/(?P<content_id>\d+)$'
.
format
(
settings
.
COURSE_ID_PATTERN
),
views
.
ProctoredExamView
.
as_view
(),
views
.
ProctoredExamView
.
as_view
(),
name
=
'edx_proctoring.proctored_exam.exam'
name
=
'edx_proctoring.proctored_exam.exam'
),
),
...
@@ -18,12 +29,12 @@ urlpatterns = patterns( # pylint: disable=invalid-name
...
@@ -18,12 +29,12 @@ urlpatterns = patterns( # pylint: disable=invalid-name
name
=
'edx_proctoring.proctored_exam.attempt'
name
=
'edx_proctoring.proctored_exam.attempt'
),
),
url
(
url
(
r'edx_proctoring/v1/proctored_exam/allowance'
,
r'edx_proctoring/v1/proctored_exam/allowance
$
'
,
views
.
ExamAllowanceView
.
as_view
(),
views
.
ExamAllowanceView
.
as_view
(),
name
=
'edx_proctoring.proctored_exam.allowance'
name
=
'edx_proctoring.proctored_exam.allowance'
),
),
url
(
url
(
r'edx_proctoring/v1/proctored_exam/active_exams_for_user'
,
r'edx_proctoring/v1/proctored_exam/active_exams_for_user
$
'
,
views
.
ActiveExamsForUserView
.
as_view
(),
views
.
ActiveExamsForUserView
.
as_view
(),
name
=
'edx_proctoring.proctored_exam.active_exams_for_user'
name
=
'edx_proctoring.proctored_exam.active_exams_for_user'
),
),
...
...
edx_proctoring/views.py
View file @
17f93b7d
...
@@ -3,12 +3,12 @@ Proctored Exams HTTP-based API endpoints
...
@@ -3,12 +3,12 @@ Proctored Exams HTTP-based API endpoints
"""
"""
import
logging
import
logging
from
django.db
import
IntegrityError
from
rest_framework
import
status
from
rest_framework
import
status
from
rest_framework.response
import
Response
from
rest_framework.response
import
Response
from
edx_proctoring.api
import
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
start_exam_attempt
,
\
from
edx_proctoring.api
import
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
start_exam_attempt
,
\
stop_exam_attempt
,
add_allowance_for_user
,
remove_allowance_for_user
,
get_active_exams_for_user
stop_exam_attempt
,
add_allowance_for_user
,
remove_allowance_for_user
,
get_active_exams_for_user
from
edx_proctoring.exceptions
import
ProctoredExamNotFoundException
,
ProctoredExamAlreadyExists
from
edx_proctoring.serializers
import
ProctoredExamSerializer
from
.utils
import
AuthenticatedAPIView
from
.utils
import
AuthenticatedAPIView
...
@@ -31,10 +31,10 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -31,10 +31,10 @@ class ProctoredExamView(AuthenticatedAPIView):
"course_id": "edX/DemoX/Demo_Course",
"course_id": "edX/DemoX/Demo_Course",
"content_id": 123,
"content_id": 123,
"exam_name": "Midterm",
"exam_name": "Midterm",
"time_limit_mins":
"90"
,
"time_limit_mins":
90
,
"is_proctored": true,
"is_proctored": true,
"external_id": "",
"external_id": "",
"is_active": true
,
"is_active": true
}
}
**POST data Parameters**
**POST data Parameters**
...
@@ -79,25 +79,25 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -79,25 +79,25 @@ class ProctoredExamView(AuthenticatedAPIView):
?course_id=edX/DemoX/Demo_Course&content_id=123
?course_id=edX/DemoX/Demo_Course&content_id=123
returns an existing exam object matching the course_id and the content_id
returns an existing exam object matching the course_id and the content_id
"""
"""
def
post
(
self
,
request
):
def
post
(
self
,
request
):
"""
"""
Http POST handler. Creates an exam.
Http POST handler. Creates an exam.
"""
"""
try
:
serializer
=
ProctoredExamSerializer
(
data
=
request
.
DATA
)
exam_id
=
create_exam
(
if
serializer
.
is_valid
():
course_id
=
request
.
DATA
.
get
(
'course_id'
,
""
),
try
:
content_id
=
request
.
DATA
.
get
(
'content_id'
,
""
),
exam_id
=
create_exam
(
**
request
.
DATA
)
exam_name
=
request
.
DATA
.
get
(
'exam_name'
,
""
),
return
Response
({
'exam_id'
:
exam_id
})
time_limit_mins
=
request
.
DATA
.
get
(
'time_limit_mins'
,
""
),
except
ProctoredExamAlreadyExists
:
is_proctored
=
True
if
request
.
DATA
.
get
(
'is_proctored'
,
"False"
)
.
lower
()
==
'true'
else
False
,
return
Response
(
external_id
=
request
.
DATA
.
get
(
'external_id'
,
""
),
status
=
status
.
HTTP_400_BAD_REQUEST
,
is_active
=
True
if
request
.
DATA
.
get
(
'is_active'
,
""
)
.
lower
()
==
'true'
else
False
,
data
=
{
"detail"
:
"Error Trying to create a duplicate exam."
}
)
)
return
Response
({
'exam_id'
:
exam_id
})
else
:
except
IntegrityError
:
return
Response
(
return
Response
(
status
=
status
.
HTTP_400_BAD_REQUEST
,
status
=
status
.
HTTP_400_BAD_REQUEST
,
data
=
{
"detail"
:
"Trying to create a duplicate exam."
}
data
=
serializer
.
errors
)
)
def
put
(
self
,
request
):
def
put
(
self
,
request
):
...
@@ -110,9 +110,9 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -110,9 +110,9 @@ class ProctoredExamView(AuthenticatedAPIView):
exam_id
=
request
.
DATA
.
get
(
'exam_id'
,
""
),
exam_id
=
request
.
DATA
.
get
(
'exam_id'
,
""
),
exam_name
=
request
.
DATA
.
get
(
'exam_name'
,
""
),
exam_name
=
request
.
DATA
.
get
(
'exam_name'
,
""
),
time_limit_mins
=
request
.
DATA
.
get
(
'time_limit_mins'
,
""
),
time_limit_mins
=
request
.
DATA
.
get
(
'time_limit_mins'
,
""
),
is_proctored
=
True
if
request
.
DATA
.
get
(
'is_proctored'
,
"False"
)
.
lower
()
==
'true'
else
False
,
is_proctored
=
request
.
DATA
.
get
(
'is_proctored'
,
False
)
,
external_id
=
request
.
DATA
.
get
(
'external_id'
,
""
),
external_id
=
request
.
DATA
.
get
(
'external_id'
,
""
),
is_active
=
True
if
request
.
DATA
.
get
(
'is_active'
,
""
)
.
lower
()
==
'true'
else
False
,
is_active
=
request
.
DATA
.
get
(
'is_active'
,
False
)
,
)
)
return
Response
({
'exam_id'
:
exam_id
})
return
Response
({
'exam_id'
:
exam_id
})
except
:
except
:
...
@@ -121,7 +121,7 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -121,7 +121,7 @@ class ProctoredExamView(AuthenticatedAPIView):
data
=
{
"detail"
:
"The exam_id does not exist."
}
data
=
{
"detail"
:
"The exam_id does not exist."
}
)
)
def
get
(
self
,
request
):
def
get
(
self
,
request
,
exam_id
=
None
,
course_id
=
None
,
content_id
=
None
):
"""
"""
HTTP GET handler.
HTTP GET handler.
Scenarios:
Scenarios:
...
@@ -130,17 +130,13 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -130,17 +130,13 @@ class ProctoredExamView(AuthenticatedAPIView):
"""
"""
course_id
=
request
.
QUERY_PARAMS
.
get
(
'course_id'
,
None
)
content_id
=
request
.
QUERY_PARAMS
.
get
(
'content_id'
,
None
)
exam_id
=
request
.
QUERY_PARAMS
.
get
(
'exam_id'
,
None
)
if
exam_id
:
if
exam_id
:
try
:
try
:
return
Response
(
return
Response
(
data
=
get_exam_by_id
(
exam_id
),
data
=
get_exam_by_id
(
exam_id
),
status
=
status
.
HTTP_200_OK
status
=
status
.
HTTP_200_OK
)
)
except
Exception
:
except
ProctoredExamNotFound
Exception
:
return
Response
(
return
Response
(
status
=
status
.
HTTP_400_BAD_REQUEST
,
status
=
status
.
HTTP_400_BAD_REQUEST
,
data
=
{
"detail"
:
"The exam_id does not exist."
}
data
=
{
"detail"
:
"The exam_id does not exist."
}
...
@@ -151,7 +147,7 @@ class ProctoredExamView(AuthenticatedAPIView):
...
@@ -151,7 +147,7 @@ class ProctoredExamView(AuthenticatedAPIView):
data
=
get_exam_by_content_id
(
course_id
,
content_id
),
data
=
get_exam_by_content_id
(
course_id
,
content_id
),
status
=
status
.
HTTP_200_OK
status
=
status
.
HTTP_200_OK
)
)
except
Exception
:
except
ProctoredExamNotFound
Exception
:
return
Response
(
return
Response
(
status
=
status
.
HTTP_400_BAD_REQUEST
,
status
=
status
.
HTTP_400_BAD_REQUEST
,
data
=
{
"detail"
:
"The exam with course_id, content_id does not exist."
}
data
=
{
"detail"
:
"The exam with course_id, content_id does not exist."
}
...
...
settings.py
View file @
17f93b7d
...
@@ -67,3 +67,5 @@ MIDDLEWARE_CLASSES = (
...
@@ -67,3 +67,5 @@ MIDDLEWARE_CLASSES = (
)
)
ROOT_URLCONF
=
'edx_proctoring.urls'
ROOT_URLCONF
=
'edx_proctoring.urls'
COURSE_ID_PATTERN
=
r'(?P<course_id>[^/+]+(/|\+)[^/+]+(/|\+)[^/]+)'
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