Commit b6218518 by David Ormsbee

Merge pull request #1055 from MITx/feature/victor/per-user-survey-urls

Feature/victor/per user survey urls
parents 15d76cb2 791a3653
......@@ -36,10 +36,12 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
"""
from datetime import datetime
from hashlib import sha1
import json
import logging
import uuid
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
......@@ -125,9 +127,9 @@ class UserProfile(models.Model):
self.meta = json.dumps(js)
class TestCenterUser(models.Model):
"""This is our representation of the User for in-person testing, and
"""This is our representation of the User for in-person testing, and
specifically for Pearson at this point. A few things to note:
* Pearson only supports Latin-1, so we have to make sure that the data we
capture here will work with that encoding.
* While we have a lot of this demographic data in UserProfile, it's much
......@@ -135,9 +137,9 @@ class TestCenterUser(models.Model):
UserProfile, but we'll need to have a step where people who are signing
up re-enter their demographic data into the fields we specify.
* Users are only created here if they register to take an exam in person.
The field names and lengths are modeled on the conventions and constraints
of Pearson's data import system, including oddities such as suffix having
of Pearson's data import system, including oddities such as suffix having
a limit of 255 while last_name only gets 50.
"""
# Our own record keeping...
......@@ -148,21 +150,21 @@ class TestCenterUser(models.Model):
# and is something Pearson needs to know to manage updates. Unlike
# updated_at, this will not get incremented when we do a batch data import.
user_updated_at = models.DateTimeField(db_index=True)
# Unique ID given to us for this User by the Testing Center. It's null when
# we first create the User entry, and is assigned by Pearson later.
candidate_id = models.IntegerField(null=True, db_index=True)
# Unique ID we assign our user for a the Test Center.
client_candidate_id = models.CharField(max_length=50, db_index=True)
# Name
first_name = models.CharField(max_length=30, db_index=True)
last_name = models.CharField(max_length=50, db_index=True)
middle_name = models.CharField(max_length=30, blank=True)
suffix = models.CharField(max_length=255, blank=True)
salutation = models.CharField(max_length=50, blank=True)
# Address
address_1 = models.CharField(max_length=40)
address_2 = models.CharField(max_length=40, blank=True)
......@@ -175,7 +177,7 @@ class TestCenterUser(models.Model):
postal_code = models.CharField(max_length=16, blank=True, db_index=True)
# country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
country = models.CharField(max_length=3, db_index=True)
# Phone
phone = models.CharField(max_length=35)
extension = models.CharField(max_length=8, blank=True, db_index=True)
......@@ -183,14 +185,28 @@ class TestCenterUser(models.Model):
fax = models.CharField(max_length=35, blank=True)
# fax_country_code required *if* fax is present.
fax_country_code = models.CharField(max_length=3, blank=True)
# Company
company_name = models.CharField(max_length=50, blank=True)
@property
def email(self):
return self.user.email
def unique_id_for_user(user):
"""
Return a unique id for a user, suitable for inserting into
e.g. personalized survey links.
Currently happens to be implemented as a sha1 hash of the username
(and thus assumes that usernames don't change).
"""
# Using the user id as the salt because it's sort of random, and is already
# in the db.
salt = str(user.id)
return sha1(salt + user.username).hexdigest()
## TODO: Should be renamed to generic UserGroup, and possibly
# Given an optional field for type of group
class UserTestGroup(models.Model):
......@@ -363,10 +379,10 @@ def replicate_user_save(sender, **kwargs):
# @receiver(post_save, sender=CourseEnrollment)
def replicate_enrollment_save(sender, **kwargs):
"""This is called when a Student enrolls in a course. It has to do the
"""This is called when a Student enrolls in a course. It has to do the
following:
1. Make sure the User is copied into the Course DB. It may already exist
1. Make sure the User is copied into the Course DB. It may already exist
(someone deleting and re-adding a course). This has to happen first or
the foreign key constraint breaks.
2. Replicate the CourseEnrollment.
......@@ -410,9 +426,9 @@ USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
def replicate_user(portal_user, course_db_name):
"""Replicate a User to the correct Course DB. This is more complicated than
it should be because Askbot extends the auth_user table and adds its own
it should be because Askbot extends the auth_user table and adds its own
fields. So we need to only push changes to the standard fields and leave
the rest alone so that Askbot changes at the Course DB level don't get
the rest alone so that Askbot changes at the Course DB level don't get
overridden.
"""
try:
......@@ -457,7 +473,7 @@ def is_valid_course_id(course_id):
"""Right now, the only database that's not a course database is 'default'.
I had nicer checking in here originally -- it would scan the courses that
were in the system and only let you choose that. But it was annoying to run
tests with, since we don't have course data for some for our course test
tests with, since we don't have course data for some for our course test
databases. Hence the lazy version.
"""
return course_id != 'default'
......
......@@ -6,11 +6,16 @@ Replace this with more appropriate tests for your application.
"""
import logging
from datetime import datetime
from hashlib import sha1
from django.test import TestCase
from mock import patch, Mock
from nose.plugins.skip import SkipTest
from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY
from .models import (User, UserProfile, CourseEnrollment,
replicate_user, USER_FIELDS_TO_COPY,
unique_id_for_user)
from .views import process_survey_link, _cert_info
COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012'
......@@ -55,7 +60,7 @@ class ReplicationTest(TestCase):
# This hasattr lameness is here because we don't want this test to be
# triggered when we're being run by CMS tests (Askbot doesn't exist
# there, so the test will fail).
#
#
# seen_response_count isn't a field we care about, so it shouldn't have
# been copied over.
if hasattr(portal_user, 'seen_response_count'):
......@@ -74,7 +79,7 @@ class ReplicationTest(TestCase):
# During this entire time, the user data should never have made it over
# to COURSE_2
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
......@@ -108,19 +113,19 @@ class ReplicationTest(TestCase):
# Grab all the copies we expect
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
......@@ -174,30 +179,112 @@ class ReplicationTest(TestCase):
portal_user.save()
portal_user_profile.gender = 'm'
portal_user_profile.save()
# Grab all the copies we expect, and make sure it doesn't end up in
# Grab all the copies we expect, and make sure it doesn't end up in
# places we don't expect.
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc"""
def test_process_survey_link(self):
username = "fred"
user = Mock(username=username)
id = unique_id_for_user(user)
link1 = "http://www.mysurvey.com"
self.assertEqual(process_survey_link(link1, user), link1)
link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}"
link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id)
self.assertEqual(process_survey_link(link2, user), link2_expected)
def test_cert_info(self):
user = Mock(username="fred")
survey_url = "http://a_survey.com"
course = Mock(end_of_course_survey_url=survey_url)
self.assertEqual(_cert_info(user, course, None),
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False,})
cert_status = {'status': 'unavailable'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False})
cert_status = {'status': 'generating', 'grade': '67'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
cert_status = {'status': 'regenerating', 'grade': '67'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
download_url = 'http://s3.edx/cert'
cert_status = {'status': 'downloadable', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'ready',
'show_disabled_download_button': False,
'show_download_url': True,
'download_url': download_url,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
# Test a course that doesn't have a survey specified
course2 = Mock(end_of_course_survey_url=None)
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course2, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False,
'grade': '67'
})
......@@ -28,7 +28,7 @@ from django.core.cache import cache
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from student.models import (Registration, UserProfile,
PendingNameChange, PendingEmailChange,
CourseEnrollment)
CourseEnrollment, unique_id_for_user)
from certificates.models import CertificateStatuses, certificate_status_for_student
......@@ -39,6 +39,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from datetime import date
from collections import namedtuple
from courseware.courses import get_courses_by_university
from courseware.access import has_access
......@@ -107,9 +108,9 @@ def get_date_for_press(publish_date):
# strip off extra months, and just use the first:
date = re.sub(multimonth_pattern, ", ", publish_date)
if re.search(day_pattern, date):
date = datetime.datetime.strptime(date, "%B %d, %Y")
else:
date = datetime.datetime.strptime(date, "%B, %Y")
date = datetime.datetime.strptime(date, "%B %d, %Y")
else:
date = datetime.datetime.strptime(date, "%B, %Y")
return date
def press(request):
......@@ -127,6 +128,73 @@ def press(request):
return render_to_response('static_templates/press.html', {'articles': articles})
def process_survey_link(survey_link, user):
"""
If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
Currently, this is sha1(user.username). Otherwise, return survey_link.
"""
return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
def cert_info(user, course):
"""
Get the certificate info needed to render the dashboard section for the given
student and course. Returns a dictionary with keys:
'status': one of 'generating', 'ready', 'notpassing', 'processing'
'show_download_url': bool
'download_url': url, only present if show_download_url is True
'show_disabled_download_button': bool -- true if state is 'generating'
'show_survey_button': bool
'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing'
"""
if not course.has_ended():
return {}
return _cert_info(user, course, certificate_status_for_student(user, course.id))
def _cert_info(user, course, cert_status):
"""
Implements the logic for cert_info -- split out for testing.
"""
default_status = 'processing'
if cert_status is None:
return {'status': default_status,
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False}
# simplify the status for the template using this lookup table
template_state = {
CertificateStatuses.generating: 'generating',
CertificateStatuses.regenerating: 'generating',
CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing',
}
status = template_state.get(cert_status['status'], default_status)
d = {'status': status,
'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating',}
if (status in ('generating', 'ready', 'notpassing') and
course.end_of_course_survey_url is not None):
d.update({
'show_survey_button': True,
'survey_url': process_survey_link(course.end_of_course_survey_url, user)})
else:
d['show_survey_button'] = False
if status == 'ready':
d['download_url'] = cert_status['download_url']
if status in ('generating', 'ready', 'notpassing'):
d['grade'] = cert_status['grade']
return d
@login_required
@ensure_csrf_cookie
def dashboard(request):
......@@ -160,12 +228,7 @@ def dashboard(request):
show_courseware_links_for = frozenset(course.id for course in courses
if has_access(request.user, course, 'load'))
# TODO: workaround to not have to zip courses and certificates in the template
# since before there is a migration to certificates
if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'):
cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses}
else:
cert_statuses = {}
cert_statuses = { course.id: cert_info(request.user, course) for course in courses}
context = {'courses': courses,
'message': message,
......
......@@ -75,7 +75,9 @@ def certificate_status_for_student(student, course_id):
This returns a dictionary with a key for status, and other information.
The status is one of the following:
unavailable - A student is not eligible for a certificate.
unavailable - No entry for this student--if they are actually in
the course, they probably have not been graded for
certificate generation yet.
generating - A request has been made to generate a certificate,
but it has not been generated yet.
regenerating - A request has been made to regenerate a certificate,
......@@ -90,7 +92,7 @@ def certificate_status_for_student(student, course_id):
"download_url".
If the student has been graded, the dictionary also contains their
grade for the course.
grade for the course with the key "grade".
'''
try:
......
......@@ -159,54 +159,43 @@
%>
% if course.has_ended() and cert_status:
<%
passing_grade = False
cert_button = False
survey_button = False
if cert_status['status'] in [CertificateStatuses.generating, CertificateStatuses.regenerating]:
if cert_status['status'] == 'generating':
status_css_class = 'course-status-certrendering'
cert_button = True
survey_button = True
passing_grade = True
elif cert_status['status'] == CertificateStatuses.downloadable:
elif cert_status['status'] == 'ready':
status_css_class = 'course-status-certavailable'
cert_button = True
survey_button = True
passing_grade = True
elif cert_status['status'] == CertificateStatuses.notpassing:
elif cert_status['status'] == 'notpassing':
status_css_class = 'course-status-certnotavailable'
survey_button = True
else:
# This is primarily the 'unavailable' state, but also 'error', 'deleted', etc.
status_css_class = 'course-status-processing'
if survey_button and not course.end_of_course_survey_url:
survey_button = False
%>
<div class="message message-status ${status_css_class} is-shown">
% if cert_status['status'] == CertificateStatuses.unavailable:
<p class="message-copy">Final course details are being wrapped up at this time.
Your final standing will be available shortly.</p>
% elif passing_grade:
<p class="message-copy">You have received a grade of
<span class="grade-value">${cert_status['grade']}</span>
in this course.</p>
% elif cert_status['status'] == CertificateStatuses.notpassing:
<p class="message-copy">You did not complete the necessary requirements for completion of this course.
</p>
% if cert_status['status'] == 'processing':
<p class="message-copy">Final course details are being wrapped up at
this time. Your final standing will be available shortly.</p>
% elif cert_status['status'] in ('generating', 'ready'):
<p class="message-copy">You have received a grade of
<span class="grade-value">${cert_status['grade']}</span>
in this course.</p>
% elif cert_status['status'] == 'notpassing':
<p class="message-copy">You did not complete the necessary requirements for
completion of this course.</p>
% endif
% if cert_button or survey_button:
% if cert_status['show_disabled_download_button'] or cert_status['show_download_url'] or cert_status['show_survey_button']:
<ul class="actions">
% if cert_button and cert_status['status'] in [CertificateStatuses.generating, CertificateStatuses.regenerating]:
<li class="action"><span class="btn disabled" href="">Your Certificate is Generating</span></li>
% elif cert_button and cert_status['status'] == CertificateStatuses.downloadable:
% if cert_status['show_disabled_download_button']:
<li class="action"><span class="btn disabled" href="">
Your Certificate is Generating</span></li>
% elif cert_status['show_download_url']:
<li class="action">
<a class="btn" href="${cert_status['download_url']}"
title="This link will open/download a PDF document">
Download Your PDF Certificate</a></li>
% endif
% if survey_button:
<li class="action"><a class="cta" href="${course.end_of_course_survey_url}">
% if cert_status['show_survey_button']:
<li class="action"><a class="cta" href="${cert_status['survey_url']}">
Complete our course feedback survey</a></li>
% endif
</ul>
......
......@@ -54,7 +54,6 @@ default_options = {
task :predjango do
sh("find . -type f -name *.pyc -delete")
sh('pip install -q --upgrade -r local-requirements.txt')
sh('git submodule update --init')
end
task :clean_test_files do
......
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