basic.py 20.5 KB
Newer Older
1 2 3 4 5
"""
Student and course analytics.

Serve miscellaneous course and student data
"""
6
import json
asadiqbal committed
7
import datetime
8
from shoppingcart.models import (
Ned Batchelder committed
9 10
    PaidCourseRegistration, CouponRedemption, CourseRegCodeItem,
    RegistrationCodeRedemption, CourseRegistrationCodeInvoiceItem
11
)
12
from django.db.models import Q
13
from django.conf import settings
14
from django.contrib.auth.models import User
15
from django.core.exceptions import ObjectDoesNotExist
16
from django.core.urlresolvers import reverse
17
from opaque_keys.edx.keys import UsageKey
18
import xmodule.graders as xmgraders
19
from microsite_configuration import microsite
20
from student.models import CourseEnrollmentAllowed
21
from edx_proctoring.api import get_all_exam_attempts
22
from courseware.models import StudentModule
asadiqbal committed
23 24 25
from certificates.models import GeneratedCertificate
from django.db.models import Count
from certificates.models import CertificateStatuses
26 27


28
STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
29
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
30
                    'level_of_education', 'mailing_address', 'goals', 'meta')
31
ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status')
32 33
ORDER_FEATURES = ('purchase_time',)

34 35 36
SALE_FEATURES = ('total_amount', 'company_name', 'company_contact_name', 'company_contact_email', 'recipient_name',
                 'recipient_email', 'customer_reference_number', 'internal_reference')

37 38 39 40 41
SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_contact_email', 'purchase_time',
                       'customer_reference_number', 'recipient_name', 'recipient_email', 'bill_to_street1',
                       'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode',
                       'bill_to_country', 'order_type',)

Miles Steele committed
42
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES
43
COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid')
44
COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active')
asadiqbal committed
45
CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason')
46

47 48
UNAVAILABLE = "[unavailable]"

49

50 51 52 53 54 55 56 57 58 59 60
def sale_order_record_features(course_id, features):
    """
    Return list of sale orders features as dictionaries.

    sales_records(course_id, ['company_name, total_codes', total_amount])
    would return [
        {'company_name': 'group_A', 'total_codes': '1', total_amount:'total_amount1 in decimal'.}
        {'company_name': 'group_B', 'total_codes': '2', total_amount:'total_amount2 in decimal'.}
        {'company_name': 'group_C', 'total_codes': '3', total_amount:'total_amount3 in decimal'.}
    ]
    """
61 62 63 64 65 66 67 68 69
    purchased_courses = PaidCourseRegistration.objects.filter(
        Q(course_id=course_id),
        Q(status='purchased') | Q(status='refunded')
    ).order_by('order')

    purchased_course_reg_codes = CourseRegCodeItem.objects.filter(
        Q(course_id=course_id),
        Q(status='purchased') | Q(status='refunded')
    ).order_by('order')
70 71 72 73 74 75 76

    def sale_order_info(purchased_course, features):
        """
        convert purchase transactions to dictionary
        """

        sale_order_features = [x for x in SALE_ORDER_FEATURES if x in features]
77
        order_item_features = [x for x in ORDER_ITEM_FEATURES if x in features]
78 79 80 81 82

        # Extracting order information
        sale_order_dict = dict((feature, getattr(purchased_course.order, feature))
                               for feature in sale_order_features)

83 84
        quantity = int(purchased_course.qty)
        unit_cost = float(purchased_course.unit_cost)
85
        sale_order_dict.update({"quantity": quantity})
86 87 88 89 90
        sale_order_dict.update({"total_amount": quantity * unit_cost})

        sale_order_dict.update({"logged_in_username": purchased_course.order.user.username})
        sale_order_dict.update({"logged_in_email": purchased_course.order.user.email})

91 92 93
        # Extracting OrderItem information of unit_cost, list_price and status
        order_item_dict = dict((feature, getattr(purchased_course, feature, None))
                               for feature in order_item_features)
94 95 96 97 98 99 100

        order_item_dict['list_price'] = purchased_course.get_list_price()

        sale_order_dict.update(
            {"total_discount": (order_item_dict['list_price'] - order_item_dict['unit_cost']) * quantity}
        )

101 102 103 104 105 106 107 108 109
        order_item_dict.update({"coupon_code": 'N/A'})

        coupon_redemption = CouponRedemption.objects.select_related('coupon').filter(order_id=purchased_course.order_id)
        # if coupon is redeemed against the order, update the information in the order_item_dict
        if coupon_redemption.exists():
            coupon_codes = [redemption.coupon.code for redemption in coupon_redemption]
            order_item_dict.update({'coupon_code': ", ".join(coupon_codes)})

        sale_order_dict.update(dict(order_item_dict.items()))
110 111 112 113

        return sale_order_dict

    csv_data = [sale_order_info(purchased_course, features) for purchased_course in purchased_courses]
114 115 116 117
    csv_data.extend(
        [sale_order_info(purchased_course_reg_code, features)
         for purchased_course_reg_code in purchased_course_reg_codes]
    )
118 119 120
    return csv_data


121 122 123 124 125 126 127 128 129 130 131
def sale_record_features(course_id, features):
    """
    Return list of sales features as dictionaries.

    sales_records(course_id, ['company_name, total_codes', total_amount])
    would return [
        {'company_name': 'group_A', 'total_codes': '1', total_amount:'total_amount1 in decimal'.}
        {'company_name': 'group_B', 'total_codes': '2', total_amount:'total_amount2 in decimal'.}
        {'company_name': 'group_C', 'total_codes': '3', total_amount:'total_amount3 in decimal'.}
    ]
    """
132
    sales = CourseRegistrationCodeInvoiceItem.objects.select_related('invoice').filter(course_id=course_id)
133 134

    def sale_records_info(sale, features):
135 136
        """
        Convert sales records to dictionary
137

138 139
        """
        invoice = sale.invoice
140 141 142 143
        sale_features = [x for x in SALE_FEATURES if x in features]
        course_reg_features = [x for x in COURSE_REGISTRATION_FEATURES if x in features]

        # Extracting sale information
144
        sale_dict = dict((feature, getattr(invoice, feature))
145 146
                         for feature in sale_features)

147 148 149
        total_used_codes = RegistrationCodeRedemption.objects.filter(
            registration_code__in=sale.courseregistrationcode_set.all()
        ).count()
150
        sale_dict.update({"invoice_number": invoice.id})
151 152 153
        sale_dict.update({"total_codes": sale.courseregistrationcode_set.all().count()})
        sale_dict.update({'total_used_codes': total_used_codes})

154
        codes = [reg_code.code for reg_code in sale.courseregistrationcode_set.all()]
155 156 157 158 159 160 161 162 163 164 165 166 167

        # Extracting registration code information
        obj_course_reg_code = sale.courseregistrationcode_set.all()[:1].get()
        course_reg_dict = dict((feature, getattr(obj_course_reg_code, feature))
                               for feature in course_reg_features)

        course_reg_dict['course_id'] = course_id.to_deprecated_string()
        course_reg_dict.update({'codes': ", ".join(codes)})
        sale_dict.update(dict(course_reg_dict.items()))

        return sale_dict

    return [sale_records_info(sale, features) for sale in sales]
168 169


asadiqbal committed
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195
def issued_certificates(course_key, features):
    """
    Return list of issued certificates as dictionaries against the given course key.

    issued_certificates(course_key, features)
    would return [
        {course_id: 'abc', 'total_issued_certificate': '5', 'mode': 'honor'}
        {course_id: 'abc', 'total_issued_certificate': '10', 'mode': 'verified'}
        {course_id: 'abc', 'total_issued_certificate': '15', 'mode': 'Professional Education'}
    ]
    """

    report_run_date = datetime.date.today().strftime("%B %d, %Y")
    certificate_features = [x for x in CERTIFICATE_FEATURES if x in features]
    generated_certificates = list(GeneratedCertificate.objects.filter(
        course_id=course_key,
        status=CertificateStatuses.downloadable
    ).values(*certificate_features).annotate(total_issued_certificate=Count('mode')))

    # Report run date
    for data in generated_certificates:
        data['report_run_date'] = report_run_date

    return generated_certificates


196
def enrolled_students_features(course_key, features):
197
    """
198 199
    Return list of student features as dictionaries.

200
    enrolled_students_features(course_key, ['username', 'first_name'])
201 202 203 204 205
    would return [
        {'username': 'username1', 'first_name': 'firstname1'}
        {'username': 'username2', 'first_name': 'firstname2'}
        {'username': 'username3', 'first_name': 'firstname3'}
    ]
206
    """
207
    include_cohort_column = 'cohort' in features
208
    include_team_column = 'team' in features
209

210
    students = User.objects.filter(
211
        courseenrollment__course_id=course_key,
212 213
        courseenrollment__is_active=1,
    ).order_by('username').select_related('profile')
214

215 216 217
    if include_cohort_column:
        students = students.prefetch_related('course_groups')

218 219 220
    if include_team_column:
        students = students.prefetch_related('teams')

221
    def extract_student(student, features):
222 223 224
        """ convert student to dictionary """
        student_features = [x for x in STUDENT_FEATURES if x in features]
        profile_features = [x for x in PROFILE_FEATURES if x in features]
225

226 227 228 229 230 231 232 233 234
        # For data extractions on the 'meta' field
        # the feature name should be in the format of 'meta.foo' where
        # 'foo' is the keyname in the meta dictionary
        meta_features = []
        for feature in features:
            if 'meta.' in feature:
                meta_key = feature.split('.')[1]
                meta_features.append((feature, meta_key))

235 236
        student_dict = dict((feature, getattr(student, feature))
                            for feature in student_features)
237
        profile = student.profile
238
        if profile is not None:
239 240 241
            profile_dict = dict((feature, getattr(profile, feature))
                                for feature in profile_features)
            student_dict.update(profile_dict)
242

243 244 245 246 247
            # now featch the requested meta fields
            meta_dict = json.loads(profile.meta) if profile.meta else {}
            for meta_feature, meta_key in meta_features:
                student_dict[meta_feature] = meta_dict.get(meta_key)

248 249 250 251 252 253 254 255
        if include_cohort_column:
            # Note that we use student.course_groups.all() here instead of
            # student.course_groups.filter(). The latter creates a fresh query,
            # therefore negating the performance gain from prefetch_related().
            student_dict['cohort'] = next(
                (cohort.name for cohort in student.course_groups.all() if cohort.course_id == course_key),
                "[unassigned]"
            )
256 257 258 259 260 261

        if include_team_column:
            student_dict['team'] = next(
                (team.name for team in student.teams.all() if team.course_id == course_key),
                UNAVAILABLE
            )
262 263
        return student_dict

264
    return [extract_student(student, features) for student in students]
265 266


267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291
def list_may_enroll(course_key, features):
    """
    Return info about students who may enroll in a course as a dict.

    list_may_enroll(course_key, ['email'])
    would return [
        {'email': 'email1'}
        {'email': 'email2'}
        {'email': 'email3'}
    ]

    Note that result does not include students who may enroll and have
    already done so.
    """
    may_enroll_and_unenrolled = CourseEnrollmentAllowed.may_enroll_and_unenrolled(course_key)

    def extract_student(student, features):
        """
        Build dict containing information about a single student.
        """
        return dict((feature, getattr(student, feature)) for feature in features)

    return [extract_student(student, features) for student in may_enroll_and_unenrolled]


292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
def get_proctored_exam_results(course_key, features):
    """
    Return info about proctored exam results in a course as a dict.
    """
    def extract_student(exam_attempt, features):
        """
        Build dict containing information about a single student exam_attempt.
        """
        proctored_exam = dict(
            (feature, exam_attempt.get(feature)) for feature in features if feature in exam_attempt
        )
        proctored_exam.update({'exam_name': exam_attempt.get('proctored_exam').get('exam_name')})
        proctored_exam.update({'user_email': exam_attempt.get('user').get('email')})

        return proctored_exam

    exam_attempts = get_all_exam_attempts(course_key)
    return [extract_student(exam_attempt, features) for exam_attempt in exam_attempts]


312
def coupon_codes_features(features, coupons_list, course_id):
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330
    """
    Return list of Coupon Codes as dictionaries.

    coupon_codes_features
    would return [
        {'course_id': 'edX/Open_DemoX/edx_demo_course,, 'discount': '213'  ..... }
        {'course_id': 'edX/Open_DemoX/edx_demo_course,, 'discount': '234'  ..... }
    ]
    """

    def extract_coupon(coupon, features):
        """ convert coupon_codes to dictionary
        :param coupon_codes:
        :param features:
        """
        coupon_features = [x for x in COUPON_FEATURES if x in features]

        coupon_dict = dict((feature, getattr(coupon, feature)) for feature in coupon_features)
331
        coupon_redemptions = coupon.couponredemption_set.filter(
332
            order__status="purchased"
333 334 335 336 337 338 339
        )

        coupon_dict['code_redeemed_count'] = coupon_redemptions.count()

        seats_purchased_using_coupon = 0
        total_discounted_amount = 0
        for coupon_redemption in coupon_redemptions:
340
            cart_items = coupon_redemption.order.orderitem_set.all().select_subclasses()
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
            found_items = []
            for item in cart_items:
                if getattr(item, 'course_id', None):
                    if item.course_id == course_id:
                        found_items.append(item)
            for order_item in found_items:
                seats_purchased_using_coupon += order_item.qty
                discounted_amount_for_item = float(
                    order_item.list_price * order_item.qty) * (float(coupon.percentage_discount) / 100)
                total_discounted_amount += discounted_amount_for_item

        coupon_dict['total_discounted_seats'] = seats_purchased_using_coupon
        coupon_dict['total_discounted_amount'] = total_discounted_amount

        # We have to capture the redeemed_by value in the case of the downloading and spent registration
356
        # codes csv. In the case of active and generated registration codes the redeemed_by value will be None.
357
        # They have not been redeemed yet
358

359
        coupon_dict['expiration_date'] = coupon.display_expiry_date
360 361 362 363 364
        coupon_dict['course_id'] = coupon_dict['course_id'].to_deprecated_string()
        return coupon_dict
    return [extract_coupon(coupon, features) for coupon in coupons_list]


365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381
def list_problem_responses(course_key, problem_location):
    """
    Return responses to a given problem as a dict.

    list_problem_responses(course_key, problem_location)

    would return [
        {'username': u'user1', 'state': u'...'},
        {'username': u'user2', 'state': u'...'},
        {'username': u'user3', 'state': u'...'},
    ]

    where `state` represents a student's response to the problem
    identified by `problem_location`.
    """
    problem_key = UsageKey.from_string(problem_location)
    # Are we dealing with an "old-style" problem location?
382
    run = problem_key.run
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399
    if not run:
        problem_key = course_key.make_usage_key_from_deprecated_string(problem_location)
    if problem_key.course_key != course_key:
        return []

    smdat = StudentModule.objects.filter(
        course_id=course_key,
        module_state_key=problem_key
    )
    smdat = smdat.order_by('student')

    return [
        {'username': response.student.username, 'state': response.state}
        for response in smdat
    ]


400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
def course_registration_features(features, registration_codes, csv_type):
    """
    Return list of Course Registration Codes as dictionaries.

    course_registration_features
    would return [
        {'code': 'code1', 'course_id': 'edX/Open_DemoX/edx_demo_course, ..... }
        {'code': 'code2', 'course_id': 'edX/Open_DemoX/edx_demo_course, ..... }
    ]
    """

    def extract_course_registration(registration_code, features, csv_type):
        """ convert registration_code to dictionary
        :param registration_code:
        :param features:
        :param csv_type:
        """
417
        site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME)
418 419 420
        registration_features = [x for x in COURSE_REGISTRATION_FEATURES if x in features]

        course_registration_dict = dict((feature, getattr(registration_code, feature)) for feature in registration_features)
421
        course_registration_dict['company_name'] = None
422
        if registration_code.invoice_item:
423
            course_registration_dict['company_name'] = registration_code.invoice_item.invoice.company_name
424
        course_registration_dict['redeemed_by'] = None
425 426
        if registration_code.invoice_item:
            sale_invoice = registration_code.invoice_item.invoice
427 428 429 430
            course_registration_dict['invoice_id'] = sale_invoice.id
            course_registration_dict['purchaser'] = sale_invoice.recipient_name
            course_registration_dict['customer_reference_number'] = sale_invoice.customer_reference_number
            course_registration_dict['internal_reference'] = sale_invoice.internal_reference
431

432 433 434 435 436
        course_registration_dict['redeem_code_url'] = 'http://{base_url}{redeem_code_url}'.format(
            base_url=site_name,
            redeem_code_url=reverse('register_code_redemption',
                                    kwargs={'registration_code': registration_code.code})
        )
437 438 439 440 441
        # we have to capture the redeemed_by value in the case of the downloading and spent registration
        # codes csv. In the case of active and generated registration codes the redeemed_by value will be None.
        #  They have not been redeemed yet
        if csv_type is not None:
            try:
442 443 444
                redemption_set = registration_code.registrationcoderedemption_set
                redeemed_by = redemption_set.get(registration_code=registration_code).redeemed_by
                course_registration_dict['redeemed_by'] = redeemed_by.email
445 446 447 448 449 450 451 452
            except ObjectDoesNotExist:
                pass

        course_registration_dict['course_id'] = course_registration_dict['course_id'].to_deprecated_string()
        return course_registration_dict
    return [extract_course_registration(code, features, csv_type) for code in registration_codes]


453 454
def dump_grading_context(course):
    """
455 456
    Render information about course grading context
    (e.g. which problems are graded in what assignments)
457 458 459 460
    Useful for debugging grading_policy.json and policy.json

    Returns HTML string
    """
461 462
    hbar = "{}\n".format("-" * 77)
    msg = hbar
463 464 465 466 467 468 469 470
    msg += "Course grader:\n"

    msg += '%s\n' % course.grader.__class__
    graders = {}
    if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
        msg += '\n'
        msg += "Graded sections:\n"
        for subgrader, category, weight in course.grader.sections:
471 472
            msg += "  subgrader=%s, type=%s, category=%s, weight=%s\n"\
                % (subgrader.__class__, subgrader.type, category, weight)
473 474
            subgrader.index = 1
            graders[subgrader.type] = subgrader
475
    msg += hbar
476
    msg += "Listing grading context for course %s\n" % course.id.to_deprecated_string()
477

478
    gcontext = course.grading_context
479 480
    msg += "graded sections:\n"

481 482 483
    msg += '%s\n' % gcontext['graded_sections'].keys()
    for (gsomething, gsvals) in gcontext['graded_sections'].items():
        msg += "--> Section %s:\n" % (gsomething)
484
        for sec in gsvals:
485
            sdesc = sec['section_descriptor']
Calen Pennington committed
486
            frmat = getattr(sdesc, 'format', None)
487
            aname = ''
488 489 490 491 492 493 494
            if frmat in graders:
                gform = graders[frmat]
                aname = '%s %02d' % (gform.short_label, gform.index)
                gform.index += 1
            elif sdesc.display_name in graders:
                gform = graders[sdesc.display_name]
                aname = '%s' % gform.short_label
495
            notes = ''
496
            if getattr(sdesc, 'score_by_attempt', False):
497
                notes = ', score by attempt!'
498 499
            msg += "      %s (format=%s, Assignment=%s%s)\n"\
                % (sdesc.display_name, frmat, aname, notes)
500
    msg += "all descriptors:\n"
501 502
    msg += "length=%d\n" % len(gcontext['all_descriptors'])
    msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
503
    return msg