basic.py 22 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.serializers.json import DjangoJSONEncoder
17
from django.core.urlresolvers import reverse
18
from opaque_keys.edx.keys import UsageKey
19
import xmodule.graders as xmgraders
20
from student.models import CourseEnrollmentAllowed, CourseEnrollment
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
from lms.djangoapps.grades.context import grading_context_for_course
27
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
28
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
29 30


31
STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
32
PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender',
33 34
                    'level_of_education', 'mailing_address', 'goals', 'meta',
                    'city', 'country')
35
ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status')
36 37
ORDER_FEATURES = ('purchase_time',)

38
SALE_FEATURES = ('total_amount', 'company_name', 'company_contact_name', 'company_contact_email', 'recipient_name',
39
                 'recipient_email', 'customer_reference_number', 'internal_reference', 'created')
40

41 42 43
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',
44
                       'bill_to_country', 'order_type', 'created')
45

Miles Steele committed
46
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES
47
COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid')
48
COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active')
asadiqbal committed
49
CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason')
50

51 52
UNAVAILABLE = "[unavailable]"

53

54 55 56 57 58 59 60 61 62 63 64
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'.}
    ]
    """
65 66 67 68 69 70 71 72 73
    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')
74 75 76 77 78 79 80

    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]
81
        order_item_features = [x for x in ORDER_ITEM_FEATURES if x in features]
82 83 84 85 86

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

87 88
        quantity = int(purchased_course.qty)
        unit_cost = float(purchased_course.unit_cost)
89
        sale_order_dict.update({"quantity": quantity})
90 91 92 93 94
        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})

95 96 97
        # 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)
98 99 100 101 102 103 104

        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}
        )

105 106 107 108 109 110 111 112 113
        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()))
114 115 116 117

        return sale_order_dict

    csv_data = [sale_order_info(purchased_course, features) for purchased_course in purchased_courses]
118 119 120 121
    csv_data.extend(
        [sale_order_info(purchased_course_reg_code, features)
         for purchased_course_reg_code in purchased_course_reg_codes]
    )
122 123 124
    return csv_data


125 126 127 128 129 130 131 132 133 134 135
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'.}
    ]
    """
136
    sales = CourseRegistrationCodeInvoiceItem.objects.select_related('invoice').filter(course_id=course_id)
137 138

    def sale_records_info(sale, features):
139 140
        """
        Convert sales records to dictionary
141

142 143
        """
        invoice = sale.invoice
144 145 146 147
        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
148
        sale_dict = dict((feature, getattr(invoice, feature))
149 150
                         for feature in sale_features)

151 152 153
        total_used_codes = RegistrationCodeRedemption.objects.filter(
            registration_code__in=sale.courseregistrationcode_set.all()
        ).count()
154
        sale_dict.update({"invoice_number": invoice.id})
155 156 157
        sale_dict.update({"total_codes": sale.courseregistrationcode_set.all().count()})
        sale_dict.update({'total_used_codes': total_used_codes})

158
        codes = [reg_code.code for reg_code in sale.courseregistrationcode_set.all()]
159 160

        # Extracting registration code information
161 162 163 164 165 166 167
        if len(codes) > 0:
            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)
        else:
            course_reg_dict = dict((feature, None)
                                   for feature in course_reg_features)
168 169 170 171 172 173 174 175

        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]
176 177


asadiqbal committed
178 179 180 181 182 183 184 185 186 187 188 189 190 191
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]
192
    generated_certificates = list(GeneratedCertificate.eligible_certificates.filter(
asadiqbal committed
193 194 195 196 197 198 199 200 201 202 203
        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


204
def enrolled_students_features(course_key, features):
205
    """
206 207
    Return list of student features as dictionaries.

208
    enrolled_students_features(course_key, ['username', 'first_name'])
209 210 211 212 213
    would return [
        {'username': 'username1', 'first_name': 'firstname1'}
        {'username': 'username2', 'first_name': 'firstname2'}
        {'username': 'username3', 'first_name': 'firstname3'}
    ]
214
    """
215
    include_cohort_column = 'cohort' in features
216
    include_team_column = 'team' in features
217 218
    include_enrollment_mode = 'enrollment_mode' in features
    include_verification_status = 'verification_status' in features
219

220
    students = User.objects.filter(
221
        courseenrollment__course_id=course_key,
222 223
        courseenrollment__is_active=1,
    ).order_by('username').select_related('profile')
224

225 226 227
    if include_cohort_column:
        students = students.prefetch_related('course_groups')

228 229 230
    if include_team_column:
        students = students.prefetch_related('teams')

231 232 233 234 235 236 237 238 239
    def extract_attr(student, feature):
        """Evaluate a student attribute that is ready for JSON serialization"""
        attr = getattr(student, feature)
        try:
            DjangoJSONEncoder().default(attr)
            return attr
        except TypeError:
            return unicode(attr)

240
    def extract_student(student, features):
241 242 243
        """ 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]
244

245 246 247 248 249 250 251 252 253
        # 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))

254
        student_dict = dict((feature, extract_attr(student, feature))
255
                            for feature in student_features)
256
        profile = student.profile
257
        if profile is not None:
258
            profile_dict = dict((feature, extract_attr(profile, feature))
259 260
                                for feature in profile_features)
            student_dict.update(profile_dict)
261

262
            # now fetch the requested meta fields
263 264 265 266
            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)

267 268 269 270 271 272 273 274
        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]"
            )
275 276 277 278 279 280

        if include_team_column:
            student_dict['team'] = next(
                (team.name for team in student.teams.all() if team.course_id == course_key),
                UNAVAILABLE
            )
281 282 283 284 285 286 287 288 289 290 291 292

        if include_enrollment_mode or include_verification_status:
            enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_key)[0]
            if include_verification_status:
                student_dict['verification_status'] = SoftwareSecurePhotoVerification.verification_status_for_user(
                    student,
                    course_key,
                    enrollment_mode
                )
            if include_enrollment_mode:
                student_dict['enrollment_mode'] = enrollment_mode

293 294
        return student_dict

295
    return [extract_student(student, features) for student in students]
296 297


298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322
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]


323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
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]


343
def coupon_codes_features(features, coupons_list, course_id):
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
    """
    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)
362
        coupon_redemptions = coupon.couponredemption_set.filter(
363
            order__status="purchased"
364 365 366 367 368 369 370
        )

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

        seats_purchased_using_coupon = 0
        total_discounted_amount = 0
        for coupon_redemption in coupon_redemptions:
371
            cart_items = coupon_redemption.order.orderitem_set.all().select_subclasses()
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
            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
387
        # codes csv. In the case of active and generated registration codes the redeemed_by value will be None.
388
        # They have not been redeemed yet
389

390
        coupon_dict['expiration_date'] = coupon.display_expiry_date
391 392 393 394 395
        coupon_dict['course_id'] = coupon_dict['course_id'].to_deprecated_string()
        return coupon_dict
    return [extract_coupon(coupon, features) for coupon in coupons_list]


396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
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?
413
    run = problem_key.run
414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430
    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
    ]


431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447
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:
        """
448
        site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME)
449 450 451
        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)
452
        course_registration_dict['company_name'] = None
453
        if registration_code.invoice_item:
454
            course_registration_dict['company_name'] = registration_code.invoice_item.invoice.company_name
455
        course_registration_dict['redeemed_by'] = None
456 457
        if registration_code.invoice_item:
            sale_invoice = registration_code.invoice_item.invoice
458 459 460 461
            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
462

463 464 465 466 467
        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})
        )
468 469 470 471 472
        # 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:
473 474 475
                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
476 477 478 479 480 481 482 483
            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]


484 485
def dump_grading_context(course):
    """
486 487
    Render information about course grading context
    (e.g. which problems are graded in what assignments)
488 489 490 491
    Useful for debugging grading_policy.json and policy.json

    Returns HTML string
    """
492 493
    hbar = "{}\n".format("-" * 77)
    msg = hbar
494 495 496 497 498 499 500
    msg += "Course grader:\n"

    msg += '%s\n' % course.grader.__class__
    graders = {}
    if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
        msg += '\n'
        msg += "Graded sections:\n"
501
        for subgrader, category, weight in course.grader.subgraders:
502 503
            msg += "  subgrader=%s, type=%s, category=%s, weight=%s\n"\
                % (subgrader.__class__, subgrader.type, category, weight)
504 505
            subgrader.index = 1
            graders[subgrader.type] = subgrader
506
    msg += hbar
507
    msg += "Listing grading context for course %s\n" % course.id.to_deprecated_string()
508

509
    gcontext = grading_context_for_course(course.id)
510 511
    msg += "graded sections:\n"

512 513
    msg += '%s\n' % gcontext['all_graded_subsections_by_type'].keys()
    for (gsomething, gsvals) in gcontext['all_graded_subsections_by_type'].items():
514
        msg += "--> Section %s:\n" % (gsomething)
515
        for sec in gsvals:
516
            sdesc = sec['subsection_block']
Calen Pennington committed
517
            frmat = getattr(sdesc, 'format', None)
518
            aname = ''
519 520 521 522 523 524 525
            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
526
            notes = ''
527
            if getattr(sdesc, 'score_by_attempt', False):
528
                notes = ', score by attempt!'
529 530
            msg += "      %s (format=%s, Assignment=%s%s)\n"\
                % (sdesc.display_name, frmat, aname, notes)
531 532
    msg += "all graded blocks:\n"
    msg += "length=%d\n" % len(gcontext['all_graded_blocks'])
533
    msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
534
    return msg