views.py 43.9 KB
Newer Older
1
import logging
2
import datetime
Will Daly committed
3
import decimal
4
import dateutil
5
import pytz
6
from ipware.ip import get_ip
7
from django.db.models import Q
8 9
from django.conf import settings
from django.contrib.auth.models import Group
10
from django.shortcuts import redirect
Will Daly committed
11 12 13 14
from django.http import (
    HttpResponse, HttpResponseRedirect, HttpResponseNotFound,
    HttpResponseBadRequest, HttpResponseForbidden, Http404
)
15
from django.utils.translation import ugettext as _
16 17 18
from commerce.api import EcommerceAPI
from commerce.exceptions import InvalidConfigurationError, ApiError
from commerce.http import InternalRequestErrorResponse
stephensanchez committed
19
from course_modes.models import CourseMode
20
from util.json_request import JsonResponse
21
from django.views.decorators.http import require_POST, require_http_methods
22
from django.core.urlresolvers import reverse
23
from django.views.decorators.csrf import csrf_exempt
24
from util.bad_request_rate_limiter import BadRequestRateLimiter
25
from util.date_utils import get_default_time_display
26
from django.contrib.auth.decorators import login_required
27
from microsite_configuration import microsite
David Baumgold committed
28
from edxmako.shortcuts import render_to_response
29
from opaque_keys.edx.locations import SlashSeparatedCourseKey
Will Daly committed
30 31
from opaque_keys.edx.locator import CourseLocator
from opaque_keys import InvalidKeyError
32
from courseware.courses import get_course_by_id
Will Daly committed
33
from config_models.decorators import require_config
Julia Hansbrough committed
34
from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport
stephensanchez committed
35 36
from student.models import CourseEnrollment, EnrollmentClosedError, CourseFullError, \
    AlreadyEnrolledError
37
from embargo import api as embargo_api
Will Daly committed
38 39 40
from .exceptions import (
    ItemAlreadyInCartException, AlreadyEnrolledInCourseException,
    CourseDoesNotExistException, ReportTypeDoesNotExistException,
41
    MultipleCouponsNotAllowedException, InvalidCartItem,
stephensanchez committed
42
    ItemNotFoundInCartException, RedemptionCodeError
Will Daly committed
43 44
)
from .models import (
45
    Order, OrderTypes,
46
    PaidCourseRegistration, OrderItem, Coupon,
47 48 49
    CertificateItem, CouponRedemption, CourseRegistrationCode,
    RegistrationCodeRedemption, CourseRegCodeItem,
    Donation, DonationConfiguration
Will Daly committed
50 51 52 53 54
)
from .processors import (
    process_postpay_callback, render_purchase_form_html,
    get_signed_purchase_params, get_purchase_endpoint
)
55

56
import json
57
from xmodule_django.models import CourseKeyField
58
from .decorators import enforce_shopping_cart_enabled
59

60

61
log = logging.getLogger("shoppingcart")
62
AUDIT_LOG = logging.getLogger("audit")
63

Julia Hansbrough committed
64 65
EVENT_NAME_USER_UPGRADED = 'edx.course.enrollment.upgrade.succeeded'

Julia Hansbrough committed
66 67 68 69 70 71 72 73
REPORT_TYPES = [
    ("refund_report", RefundReport),
    ("itemized_purchase_report", ItemizedPurchaseReport),
    ("university_revenue_share", UniversityRevenueShareReport),
    ("certificate_status", CertificateStatusReport),
]


Julia Hansbrough committed
74
def initialize_report(report_type, start_date, end_date, start_letter=None, end_letter=None):
Julia Hansbrough committed
75 76 77 78 79
    """
    Creates the appropriate type of Report object based on the string report_type.
    """
    for item in REPORT_TYPES:
        if report_type in item:
Julia Hansbrough committed
80
            return item[1](start_date, end_date, start_letter, end_letter)
Julia Hansbrough committed
81
    raise ReportTypeDoesNotExistException
Diana Huang committed
82

Will Daly committed
83

84
@require_POST
85
def add_course_to_cart(request, course_id):
86 87 88 89
    """
    Adds course specified by course_id to the cart.  The model function add_to_order does all the
    heavy lifting (logging, error checking, etc)
    """
90 91

    assert isinstance(course_id, basestring)
92
    if not request.user.is_authenticated():
93
        log.info(u"Anon user trying to add course %s to cart", course_id)
94
        return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart'))
95
    cart = Order.get_cart_for_user(request.user)
96
    course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
97
    # All logging from here handled by the model
98
    try:
99
        paid_course_item = PaidCourseRegistration.add_to_order(cart, course_key)
100
    except CourseDoesNotExistException:
101
        return HttpResponseNotFound(_('The course you requested does not exist.'))
102
    except ItemAlreadyInCartException:
103
        return HttpResponseBadRequest(_('The course {course_id} is already in your cart.').format(course_id=course_id))
104
    except AlreadyEnrolledInCourseException:
105
        return HttpResponseBadRequest(
106
            _('You are already registered in course {course_id}.').format(course_id=course_id))
107 108 109 110 111 112 113 114 115 116 117
    else:
        # in case a coupon redemption code has been applied, new items should also get a discount if applicable.
        order = paid_course_item.order
        order_items = order.orderitem_set.all().select_subclasses()
        redeemed_coupons = CouponRedemption.objects.filter(order=order)
        for redeemed_coupon in redeemed_coupons:
            if Coupon.objects.filter(code=redeemed_coupon.coupon.code, course_id=course_key, is_active=True).exists():
                coupon = Coupon.objects.get(code=redeemed_coupon.coupon.code, course_id=course_key, is_active=True)
                CouponRedemption.add_coupon_redemption(coupon, order, order_items)
                break  # Since only one code can be applied to the cart, we'll just take the first one and then break.

118
    return HttpResponse(_("Course added to cart."))
119

120 121

@login_required
122
@enforce_shopping_cart_enabled
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
def update_user_cart(request):
    """
    when user change the number-of-students from the UI then
    this method Update the corresponding qty field in OrderItem model and update the order_type in order model.
    """
    try:
        qty = int(request.POST.get('qty', -1))
    except ValueError:
        log.exception('Quantity must be an integer.')
        return HttpResponseBadRequest('Quantity must be an integer.')

    if not 1 <= qty <= 1000:
        log.warning('Quantity must be between 1 and 1000.')
        return HttpResponseBadRequest('Quantity must be between 1 and 1000.')

    item_id = request.POST.get('ItemId', None)
    if item_id:
        try:
            item = OrderItem.objects.get(id=item_id, status='cart')
        except OrderItem.DoesNotExist:
143
            log.exception(u'Cart OrderItem id=%s DoesNotExist', item_id)
144 145 146 147
            return HttpResponseNotFound('Order item does not exist.')

        item.qty = qty
        item.save()
148
        old_to_new_id_map = item.order.update_order_type()
149
        total_cost = item.order.total_cost
150

151 152 153 154 155 156 157 158 159 160 161 162 163
        callback_url = request.build_absolute_uri(
            reverse("shoppingcart.views.postpay_callback")
        )
        cart = Order.get_cart_for_user(request.user)
        form_html = render_purchase_form_html(cart, callback_url=callback_url)

        return JsonResponse(
            {
                "total_cost": total_cost,
                "oldToNewIdMap": old_to_new_id_map,
                "form_html": form_html,
            }
        )
164 165 166 167 168

    return HttpResponseBadRequest('Order item not found in request.')


@login_required
169
@enforce_shopping_cart_enabled
170
def show_cart(request):
171 172 173
    """
    This view shows cart items.
    """
174
    cart = Order.get_cart_for_user(request.user)
175 176
    is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples = \
        verify_for_closed_enrollment(request.user, cart)
177
    site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME)
178

179 180 181 182 183 184 185
    if is_any_course_expired:
        for expired_item in expired_cart_items:
            Order.remove_cart_item_from_order(expired_item)
        cart.update_order_type()

    appended_expired_course_names = ", ".join(expired_cart_item_names)

186 187 188 189 190
    callback_url = request.build_absolute_uri(
        reverse("shoppingcart.views.postpay_callback")
    )
    form_html = render_purchase_form_html(cart, callback_url=callback_url)
    context = {
191
        'order': cart,
192 193 194 195
        'shoppingcart_items': valid_cart_item_tuples,
        'amount': cart.total_cost,
        'is_course_enrollment_closed': is_any_course_expired,
        'appended_expired_course_names': appended_expired_course_names,
196
        'site_name': site_name,
197
        'form_html': form_html,
198 199
        'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
        'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0],
200
    }
201
    return render_to_response("shoppingcart/shopping_cart.html", context)
202

Diana Huang committed
203

204
@login_required
205
@enforce_shopping_cart_enabled
206
def clear_cart(request):
207
    cart = Order.get_cart_for_user(request.user)
208
    cart.clear()
209 210 211
    coupon_redemption = CouponRedemption.objects.filter(user=request.user, order=cart.id)
    if coupon_redemption:
        coupon_redemption.delete()
212 213 214 215 216
        log.info(
            u'Coupon redemption entry removed for user %s for order %s',
            request.user,
            cart.id,
        )
217

218 219
    return HttpResponse('Cleared')

Diana Huang committed
220

221
@login_required
222
@enforce_shopping_cart_enabled
223
def remove_item(request):
224 225 226
    """
    This will remove an item from the user cart and also delete the corresponding coupon codes redemption.
    """
227
    item_id = request.REQUEST.get('id', '-1')
228 229 230 231

    items = OrderItem.objects.filter(id=item_id, status='cart').select_subclasses()

    if not len(items):
232 233 234 235
        log.exception(
            u'Cannot remove cart OrderItem id=%s. DoesNotExist or item is already purchased',
            item_id
        )
236 237
    else:
        item = items[0]
238
        if item.user == request.user:
239
            order_item_course_id = getattr(item, 'course_id')
240
            item.delete()
241 242 243 244 245
            log.info(
                u'order item %s removed for user %s',
                item_id,
                request.user,
            )
246
            remove_code_redemption(order_item_course_id, item_id, item, request.user)
247
            item.order.update_order_type()
248

249 250
    return HttpResponse('OK')

251

252 253 254
def remove_code_redemption(order_item_course_id, item_id, item, user):
    """
    If an item removed from shopping cart then we will remove
255
    the corresponding redemption info of coupon code
256 257 258
    """
    try:
        # Try to remove redemption information of coupon code, If exist.
259 260 261 262 263 264
        coupon_redemption = CouponRedemption.objects.get(
            user=user,
            coupon__course_id=order_item_course_id if order_item_course_id else CourseKeyField.Empty,
            order=item.order_id
        )
        coupon_redemption.delete()
265 266 267 268 269 270
        log.info(
            u'Coupon "%s" redemption entry removed for user "%s" for order item "%s"',
            coupon_redemption.coupon.code,
            user,
            item_id,
        )
271
    except CouponRedemption.DoesNotExist:
272
        log.debug(u'Code redemption does not exist for order item id=%s.', item_id)
273 274 275


@login_required
276
@enforce_shopping_cart_enabled
277 278 279 280 281 282 283 284
def reset_code_redemption(request):
    """
    This method reset the code redemption from user cart items.
    """
    cart = Order.get_cart_for_user(request.user)
    cart.reset_cart_items_prices()
    CouponRedemption.delete_coupon_redemption(request.user, cart)
    return HttpResponse('reset')
285

286

287
@login_required
288
@enforce_shopping_cart_enabled
289
def use_code(request):
290
    """
291 292 293 294
    Valid Code can be either Coupon or Registration code.
    For a valid Coupon Code, this applies the coupon code and generates a discount against all applicable items.
    For a valid Registration code, it deletes the item from the shopping cart and redirects to the
    Registration Code Redemption page.
295
    """
296
    code = request.POST["code"]
297 298 299 300 301 302
    coupons = Coupon.objects.filter(
        Q(code=code),
        Q(is_active=True),
        Q(expiration_date__gt=datetime.datetime.now(pytz.UTC)) |
        Q(expiration_date__isnull=True)
    )
asadiqbal08 committed
303
    if not coupons:
304
        # If no coupons then we check that code against course registration code
305 306 307
        try:
            course_reg = CourseRegistrationCode.objects.get(code=code)
        except CourseRegistrationCode.DoesNotExist:
308
            return HttpResponseNotFound(_("Discount does not exist against code '{code}'.").format(code=code))
309 310 311

        return use_registration_code(course_reg, request.user)

asadiqbal08 committed
312
    return use_coupon_code(coupons, request.user)
313 314


315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334
def get_reg_code_validity(registration_code, request, limiter):
    """
    This function checks if the registration code is valid, and then checks if it was already redeemed.
    """
    reg_code_already_redeemed = False
    course_registration = None
    try:
        course_registration = CourseRegistrationCode.objects.get(code=registration_code)
    except CourseRegistrationCode.DoesNotExist:
        reg_code_is_valid = False
    else:
        reg_code_is_valid = True
        try:
            RegistrationCodeRedemption.objects.get(registration_code__code=registration_code)
        except RegistrationCodeRedemption.DoesNotExist:
            reg_code_already_redeemed = False
        else:
            reg_code_already_redeemed = True

    if not reg_code_is_valid:
335
        # tick the rate limiter counter
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
        AUDIT_LOG.info("Redemption of a non existing RegistrationCode {code}".format(code=registration_code))
        limiter.tick_bad_request_counter(request)
        raise Http404()

    return reg_code_is_valid, reg_code_already_redeemed, course_registration


@require_http_methods(["GET", "POST"])
@login_required
def register_code_redemption(request, registration_code):
    """
    This view allows the student to redeem the registration code
    and enroll in the course.
    """

    # Add some rate limiting here by re-using the RateLimitMixin as a helper class
    site_name = microsite.get_value('SITE_NAME', settings.SITE_NAME)
    limiter = BadRequestRateLimiter()
    if limiter.is_rate_limit_exceeded(request):
        AUDIT_LOG.warning("Rate limit exceeded in registration code redemption.")
        return HttpResponseForbidden()

358
    template_to_render = 'shoppingcart/registration_code_redemption.html'
359 360 361 362
    if request.method == "GET":
        reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity(registration_code,
                                                                                                  request, limiter)
        course = get_course_by_id(getattr(course_registration, 'course_id'), depth=0)
363 364 365 366 367 368 369 370 371

        # Restrict the user from enrolling based on country access rules
        embargo_redirect = embargo_api.redirect_if_blocked(
            course.id, user=request.user, ip_address=get_ip(request),
            url=request.path
        )
        if embargo_redirect is not None:
            return redirect(embargo_redirect)

372 373 374 375 376 377
        context = {
            'reg_code_already_redeemed': reg_code_already_redeemed,
            'reg_code_is_valid': reg_code_is_valid,
            'reg_code': registration_code,
            'site_name': site_name,
            'course': course,
378
            'registered_for_course': not _is_enrollment_code_an_update(course, request.user, course_registration)
379 380 381 382 383 384
        }
        return render_to_response(template_to_render, context)
    elif request.method == "POST":
        reg_code_is_valid, reg_code_already_redeemed, course_registration = get_reg_code_validity(registration_code,
                                                                                                  request, limiter)
        course = get_course_by_id(getattr(course_registration, 'course_id'), depth=0)
385 386 387 388 389 390 391 392 393

        # Restrict the user from enrolling based on country access rules
        embargo_redirect = embargo_api.redirect_if_blocked(
            course.id, user=request.user, ip_address=get_ip(request),
            url=request.path
        )
        if embargo_redirect is not None:
            return redirect(embargo_redirect)

stephensanchez committed
394 395 396 397 398 399 400
        context = {
            'reg_code': registration_code,
            'site_name': site_name,
            'course': course,
            'reg_code_is_valid': reg_code_is_valid,
            'reg_code_already_redeemed': reg_code_already_redeemed,
        }
401
        if reg_code_is_valid and not reg_code_already_redeemed:
402 403 404 405 406 407 408 409 410 411 412 413
            # remove the course from the cart if it was added there.
            cart = Order.get_cart_for_user(request.user)
            try:
                cart_items = cart.find_item_by_course_id(course_registration.course_id)

            except ItemNotFoundInCartException:
                pass
            else:
                for cart_item in cart_items:
                    if isinstance(cart_item, PaidCourseRegistration) or isinstance(cart_item, CourseRegCodeItem):
                        cart_item.delete()

414
            #now redeem the reg code.
415
            redemption = RegistrationCodeRedemption.create_invoice_generated_registration_redemption(course_registration, request.user)
stephensanchez committed
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437
            try:
                kwargs = {}
                if course_registration.mode_slug is not None:
                    if CourseMode.mode_for_course(course.id, course_registration.mode_slug):
                        kwargs['mode'] = course_registration.mode_slug
                    else:
                        raise RedemptionCodeError()
                redemption.course_enrollment = CourseEnrollment.enroll(request.user, course.id, **kwargs)
                redemption.save()
                context['redemption_success'] = True
            except RedemptionCodeError:
                context['redeem_code_error'] = True
                context['redemption_success'] = False
            except EnrollmentClosedError:
                context['enrollment_closed'] = True
                context['redemption_success'] = False
            except CourseFullError:
                context['course_full'] = True
                context['redemption_success'] = False
            except AlreadyEnrolledError:
                context['registered_for_course'] = True
                context['redemption_success'] = False
438
        else:
stephensanchez committed
439
            context['redemption_success'] = False
440 441 442
        return render_to_response(template_to_render, context)


443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465
def _is_enrollment_code_an_update(course, user, redemption_code):
    """Checks to see if the user's enrollment can be updated by the code.

    Check to see if the enrollment code and the user's enrollment match. If they are different, the code
    may be used to alter the enrollment of the user. If the enrollment is inactive, will return True, since
    the user may use the code to re-activate an enrollment as well.

    Enrollment redemption codes must be associated with a paid course mode. If the current enrollment is a
    different mode then the mode associated with the code, use of the code can be considered an upgrade.

    Args:
        course (CourseDescriptor): The course to check for enrollment.
        user (User): The user that will be using the redemption code.
        redemption_code (CourseRegistrationCode): The redemption code that will be used to update the user's enrollment.

    Returns:
        True if the redemption code can be used to upgrade the enrollment, or re-activate it.

    """
    enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course.id)
    return not is_active or enrollment_mode != redemption_code.mode_slug


466 467
def use_registration_code(course_reg, user):
    """
468 469 470 471
    This method utilize course registration code.
    If the registration code is already redeemed, it returns an error.
    Else, it identifies and removes the applicable OrderItem from the Order
    and redirects the user to the Registration code redemption page.
472
    """
473
    if RegistrationCodeRedemption.is_registration_code_redeemed(course_reg):
474
        log.warning(u"Registration code '%s' already used", course_reg.code)
475 476 477 478 479
        return HttpResponseBadRequest(
            _("Oops! The code '{registration_code}' you entered is either invalid or expired").format(
                registration_code=course_reg.code
            )
        )
480 481
    try:
        cart = Order.get_cart_for_user(user)
482 483
        cart_items = cart.find_item_by_course_id(course_reg.course_id)
    except ItemNotFoundInCartException:
484
        log.warning(u"Course item does not exist against registration code '%s'", course_reg.code)
485 486 487 488 489
        return HttpResponseNotFound(
            _("Code '{registration_code}' is not valid for any course in the shopping cart.").format(
                registration_code=course_reg.code
            )
        )
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505
    else:
        applicable_cart_items = [
            cart_item for cart_item in cart_items
            if (
                (isinstance(cart_item, PaidCourseRegistration) or isinstance(cart_item, CourseRegCodeItem))and cart_item.qty == 1
            )
        ]
        if not applicable_cart_items:
            return HttpResponseNotFound(
                _("Cart item quantity should not be greater than 1 when applying activation code"))

    redemption_url = reverse('register_code_redemption', kwargs={'registration_code': course_reg.code})
    return HttpResponse(
        json.dumps({'response': 'success', 'coupon_code_applied': False, 'redemption_url': redemption_url}),
        content_type="application/json"
    )
506

507

asadiqbal08 committed
508
def use_coupon_code(coupons, user):
509 510 511
    """
    This method utilize course coupon code
    """
asadiqbal08 committed
512 513 514 515 516 517 518 519 520 521 522
    cart = Order.get_cart_for_user(user)
    cart_items = cart.orderitem_set.all().select_subclasses()
    is_redemption_applied = False
    for coupon in coupons:
        try:
            if CouponRedemption.add_coupon_redemption(coupon, cart, cart_items):
                is_redemption_applied = True
        except MultipleCouponsNotAllowedException:
            return HttpResponseBadRequest(_("Only one coupon redemption is allowed against an order"))

    if not is_redemption_applied:
523
        log.warning(u"Discount does not exist against code '%s'.", coupons[0].code)
524
        return HttpResponseNotFound(_("Discount does not exist against code '{code}'.").format(code=coupons[0].code))
525

526 527 528 529
    return HttpResponse(
        json.dumps({'response': 'success', 'coupon_code_applied': True}),
        content_type="application/json"
    )
530 531


Will Daly committed
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601
@require_config(DonationConfiguration)
@require_POST
@login_required
def donate(request):
    """Add a single donation item to the cart and proceed to payment.

    Warning: this call will clear all the items in the user's cart
    before adding the new item!

    Arguments:
        request (Request): The Django request object.  This should contain
            a JSON-serialized dictionary with "amount" (string, required),
            and "course_id" (slash-separated course ID string, optional).

    Returns:
        HttpResponse: 200 on success with JSON-encoded dictionary that has keys
            "payment_url" (string) and "payment_params" (dictionary).  The client
            should POST the payment params to the payment URL.
        HttpResponse: 400 invalid amount or course ID.
        HttpResponse: 404 donations are disabled.
        HttpResponse: 405 invalid request method.

    Example usage:

        POST /shoppingcart/donation/
        with params {'amount': '12.34', course_id': 'edX/DemoX/Demo_Course'}
        will respond with the signed purchase params
        that the client can send to the payment processor.

    """
    amount = request.POST.get('amount')
    course_id = request.POST.get('course_id')

    # Check that required parameters are present and valid
    if amount is None:
        msg = u"Request is missing required param 'amount'"
        log.error(msg)
        return HttpResponseBadRequest(msg)
    try:
        amount = (
            decimal.Decimal(amount)
        ).quantize(
            decimal.Decimal('.01'),
            rounding=decimal.ROUND_DOWN
        )
    except decimal.InvalidOperation:
        return HttpResponseBadRequest("Could not parse 'amount' as a decimal")

    # Any amount is okay as long as it's greater than 0
    # Since we've already quantized the amount to 0.01
    # and rounded down, we can check if it's less than 0.01
    if amount < decimal.Decimal('0.01'):
        return HttpResponseBadRequest("Amount must be greater than 0")

    if course_id is not None:
        try:
            course_id = CourseLocator.from_string(course_id)
        except InvalidKeyError:
            msg = u"Request included an invalid course key: {course_key}".format(course_key=course_id)
            log.error(msg)
            return HttpResponseBadRequest(msg)

    # Add the donation to the user's cart
    cart = Order.get_cart_for_user(request.user)
    cart.clear()

    try:
        # Course ID may be None if this is a donation to the entire organization
        Donation.add_to_order(cart, amount, course_id=course_id)
    except InvalidCartItem as ex:
602 603 604 605 606
        log.exception(
            u"Could not create donation item for amount '%s' and course ID '%s'",
            amount,
            course_id
        )
Will Daly committed
607 608 609 610 611 612 613 614 615 616 617 618 619 620
        return HttpResponseBadRequest(unicode(ex))

    # Start the purchase.
    # This will "lock" the purchase so the user can't change
    # the amount after we send the information to the payment processor.
    # If the user tries to make another donation, it will be added
    # to a new cart.
    cart.start_purchase()

    # Construct the response params (JSON-encoded)
    callback_url = request.build_absolute_uri(
        reverse("shoppingcart.views.postpay_callback")
    )

621 622 623 624 625 626
    # Add extra to make it easier to track transactions
    extra_data = [
        unicode(course_id) if course_id else "",
        "donation_course" if course_id else "donation_general"
    ]

Will Daly committed
627 628 629 630 631 632 633 634
    response_params = json.dumps({
        # The HTTP end-point for the payment processor.
        "payment_url": get_purchase_endpoint(),

        # Parameters the client should send to the payment processor
        "payment_params": get_signed_purchase_params(
            cart,
            callback_url=callback_url,
635
            extra_data=extra_data
Will Daly committed
636 637 638 639 640 641
        ),
    })

    return HttpResponse(response_params, content_type="text/json")


642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678
def _get_verify_flow_redirect(order):
    """Check if we're in the verification flow and redirect if necessary.

    Arguments:
        order (Order): The order received by the post-pay callback.

    Returns:
        HttpResponseRedirect or None

    """
    # See if the order contained any certificate items
    # If so, the user is coming from the payment/verification flow.
    cert_items = CertificateItem.objects.filter(order=order)

    if cert_items.count() > 0:
        # Currently, we allow the purchase of only one verified
        # enrollment at a time; if there are more than one,
        # this will choose the first.
        if cert_items.count() > 1:
            log.warning(
                u"More than one certificate item in order %s; "
                u"continuing with the payment/verification flow for "
                u"the first order item (course %s).",
                order.id, cert_items[0].course_id
            )

        course_id = cert_items[0].course_id
        url = reverse(
            'verify_student_payment_confirmation',
            kwargs={'course_id': unicode(course_id)}
        )
        # Add a query string param for the order ID
        # This allows the view to query for the receipt information later.
        url += '?payment-order-num={order_num}'.format(order_num=order.id)
        return HttpResponseRedirect(url)


679
@csrf_exempt
Diana Huang committed
680
@require_POST
681
def postpay_callback(request):
682
    """
683 684 685 686 687 688 689
    Receives the POST-back from processor.
    Mainly this calls the processor-specific code to check if the payment was accepted, and to record the order
    if it was, and to generate an error page.
    If successful this function should have the side effect of changing the "cart" into a full "order" in the DB.
    The cart can then render a success page which links to receipt pages.
    If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be
    returned.
690
    """
691 692
    params = request.POST.dict()
    result = process_postpay_callback(params)
693

694
    if result['success']:
695 696 697
        # See if this payment occurred as part of the verification flow process
        # If so, send the user back into the flow so they have the option
        # to continue with verification.
Awais committed
698 699 700 701 702 703 704 705 706 707 708 709 710

        # Only orders where order_items.count() == 1 might be attempting to upgrade
        attempting_upgrade = request.session.get('attempting_upgrade', False)
        if attempting_upgrade:
            if result['order'].has_items(CertificateItem):
                course_id = result['order'].orderitem_set.all().select_subclasses("certificateitem")[0].course_id
                if course_id:
                    course_enrollment = CourseEnrollment.get_enrollment(request.user, course_id)
                    if course_enrollment:
                        course_enrollment.emit_event(EVENT_NAME_USER_UPGRADED)

            request.session['attempting_upgrade'] = False

711 712 713 714 715
        verify_flow_redirect = _get_verify_flow_redirect(result['order'])
        if verify_flow_redirect is not None:
            return verify_flow_redirect

        # Otherwise, send the user to the receipt page
716
        return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id]))
717
    else:
Awais committed
718
        request.session['attempting_upgrade'] = False
Diana Huang committed
719
        return render_to_response('shoppingcart/error.html', {'order': result['order'],
720
                                                              'error_html': result['error_html']})
721

722 723 724

@require_http_methods(["GET", "POST"])
@login_required
725
@enforce_shopping_cart_enabled
726 727 728 729 730 731 732
def billing_details(request):
    """
    This is the view for capturing additional billing details
    in case of the business purchase workflow.
    """

    cart = Order.get_cart_for_user(request.user)
733
    cart_items = cart.orderitem_set.all().select_subclasses()
734 735 736 737 738 739 740 741 742 743 744 745 746
    if getattr(cart, 'order_type') != OrderTypes.BUSINESS:
        raise Http404('Page not found!')

    if request.method == "GET":
        callback_url = request.build_absolute_uri(
            reverse("shoppingcart.views.postpay_callback")
        )
        form_html = render_purchase_form_html(cart, callback_url=callback_url)
        total_cost = cart.total_cost
        context = {
            'shoppingcart_items': cart_items,
            'amount': total_cost,
            'form_html': form_html,
747 748
            'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
            'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0],
749 750 751 752 753 754 755 756 757 758 759 760 761
            'site_name': microsite.get_value('SITE_NAME', settings.SITE_NAME),
        }
        return render_to_response("shoppingcart/billing_details.html", context)
    elif request.method == "POST":
        company_name = request.POST.get("company_name", "")
        company_contact_name = request.POST.get("company_contact_name", "")
        company_contact_email = request.POST.get("company_contact_email", "")
        recipient_name = request.POST.get("recipient_name", "")
        recipient_email = request.POST.get("recipient_email", "")
        customer_reference_number = request.POST.get("customer_reference_number", "")

        cart.add_billing_details(company_name, company_contact_name, company_contact_email, recipient_name,
                                 recipient_email, customer_reference_number)
762 763 764

        is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user)

765
        return JsonResponse({
766 767
            'response': _('success'),
            'is_course_enrollment_closed': is_any_course_expired
768 769 770
        })  # status code 200: OK by default


771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820
def verify_for_closed_enrollment(user, cart=None):
    """
    A multi-output helper function.
    inputs:
        user: a user object
        cart: If a cart is provided it uses the same object, otherwise fetches the user's cart.
    Returns:
        is_any_course_expired: True if any of the items in the cart has it's enrollment period closed. False otherwise.
        expired_cart_items: List of courses with enrollment period closed.
        expired_cart_item_names: List of names of the courses with enrollment period closed.
        valid_cart_item_tuples: List of courses which are still open for enrollment.
    """
    if cart is None:
        cart = Order.get_cart_for_user(user)
    expired_cart_items = []
    expired_cart_item_names = []
    valid_cart_item_tuples = []
    cart_items = cart.orderitem_set.all().select_subclasses()
    is_any_course_expired = False
    for cart_item in cart_items:
        course_key = getattr(cart_item, 'course_id', None)
        if course_key is not None:
            course = get_course_by_id(course_key, depth=0)
            if CourseEnrollment.is_enrollment_closed(user, course):
                is_any_course_expired = True
                expired_cart_items.append(cart_item)
                expired_cart_item_names.append(course.display_name)
            else:
                valid_cart_item_tuples.append((cart_item, course))

    return is_any_course_expired, expired_cart_items, expired_cart_item_names, valid_cart_item_tuples


@require_http_methods(["GET"])
@login_required
@enforce_shopping_cart_enabled
def verify_cart(request):
    """
    Called when the user clicks the button to transfer control to CyberSource.
    Returns a JSON response with is_course_enrollment_closed set to True if any of the courses has its
    enrollment period closed. If all courses are still valid, is_course_enrollment_closed set to False.
    """
    is_any_course_expired, __, __, __ = verify_for_closed_enrollment(request.user)
    return JsonResponse(
        {
            'is_course_enrollment_closed': is_any_course_expired
        }
    )  # status code 200: OK by default


821
@login_required
822 823 824 825 826
def show_receipt(request, ordernum):
    """
    Displays a receipt for a particular order.
    404 if order is not yet purchased or request.user != order.user
    """
827
    is_json_request = 'application/json' in request.META.get('HTTP_ACCEPT', "")
828 829
    try:
        order = Order.objects.get(id=ordernum)
830 831 832 833 834
    except (Order.DoesNotExist, ValueError):
        if is_json_request:
            return _get_external_order(request, ordernum)
        else:
            raise Http404('Order not found!')
835

836
    if order.user != request.user or order.status not in ['purchased', 'refunded']:
837 838
        raise Http404('Order not found!')

839
    if is_json_request:
840 841 842 843 844
        return _show_receipt_json(order)
    else:
        return _show_receipt_html(request, order)


845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911
def _get_external_order(request, order_number):
    """Get the order context from the external E-Commerce Service.

    Get information about an order. This function makes a request to the E-Commerce Service to see if there is
    order information that can be used to render a receipt for the user.

    Args:
        request (Request): The request for the the receipt.
        order_number (str) : The order number.

    Returns:
        dict: A serializable dictionary of the receipt page context based on an order returned from the E-Commerce
            Service.

    """
    try:
        api = EcommerceAPI()
        order_number, order_status, order_data = api.get_order(request.user, order_number)
        billing = order_data.get('billing_address', {})
        country = billing.get('country', {})

        # In order to get the date this order was paid, we need to check for payment sources, and associated
        # transactions.
        payment_dates = []
        for source in order_data.get('sources', []):
            for transaction in source.get('transactions', []):
                payment_dates.append(dateutil.parser.parse(transaction['date_created']))
        payment_date = sorted(payment_dates, reverse=True).pop()

        order_info = {
            'orderNum': order_number,
            'currency': order_data['currency'],
            'status': order_status,
            'purchase_datetime': get_default_time_display(payment_date),
            'total_cost': order_data['total_excl_tax'],
            'billed_to': {
                'first_name': billing.get('first_name', ''),
                'last_name': billing.get('last_name', ''),
                'street1': billing.get('line1', ''),
                'street2': billing.get('line2', ''),
                'city': billing.get('line4', ''),  # 'line4' is the City, from the E-Commerce Service
                'state': billing.get('state', ''),
                'postal_code': billing.get('postcode', ''),
                'country': country.get('display_name', ''),
            },
            'items': [
                {
                    'quantity': item['quantity'],
                    'unit_cost': item['unit_price_excl_tax'],
                    'line_cost': item['line_price_excl_tax'],
                    'line_desc': item['description']
                }
                for item in order_data['lines']
            ]
        }
        return JsonResponse(order_info)
    except InvalidConfigurationError:
        msg = u"E-Commerce API not setup. Cannot request Order [{order_number}] for User [{user_id}] ".format(
            user_id=request.user.id, order_number=order_number
        )
        log.debug(msg)
        return JsonResponse(status=500, object={'error_message': msg})
    except ApiError as err:
        # The API will handle logging of the error.
        return InternalRequestErrorResponse(err.message)


912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967
def _show_receipt_json(order):
    """Render the receipt page as JSON.

    The included information is deliberately minimal:
    as much as possible, the included information should
    be common to *all* order items, so the client doesn't
    need to handle different item types differently.

    Arguments:
        request (HttpRequest): The request for the receipt.
        order (Order): The order model to display.

    Returns:
        HttpResponse

    """
    order_info = {
        'orderNum': order.id,
        'currency': order.currency,
        'status': order.status,
        'purchase_datetime': get_default_time_display(order.purchase_time) if order.purchase_time else None,
        'billed_to': {
            'first_name': order.bill_to_first,
            'last_name': order.bill_to_last,
            'street1': order.bill_to_street1,
            'street2': order.bill_to_street2,
            'city': order.bill_to_city,
            'state': order.bill_to_state,
            'postal_code': order.bill_to_postalcode,
            'country': order.bill_to_country,
        },
        'total_cost': order.total_cost,
        'items': [
            {
                'quantity': item.qty,
                'unit_cost': item.unit_cost,
                'line_cost': item.line_cost,
                'line_desc': item.line_desc
            }
            for item in OrderItem.objects.filter(order=order).select_subclasses()
        ]
    }
    return JsonResponse(order_info)


def _show_receipt_html(request, order):
    """Render the receipt page as HTML.

    Arguments:
        request (HttpRequest): The request for the receipt.
        order (Order): The order model to display.

    Returns:
        HttpResponse

    """
968
    order_items = OrderItem.objects.filter(order=order).select_subclasses()
969 970 971 972 973 974 975 976 977 978
    shoppingcart_items = []
    course_names_list = []
    for order_item in order_items:
        course_key = getattr(order_item, 'course_id')
        if course_key:
            course = get_course_by_id(course_key, depth=0)
            shoppingcart_items.append((order_item, course))
            course_names_list.append(course.display_name)

    appended_course_names = ", ".join(course_names_list)
Diana Huang committed
979
    any_refunds = any(i.status == "refunded" for i in order_items)
980
    receipt_template = 'shoppingcart/receipt.html'
981
    __, instructions = order.generate_receipt_instructions()
982 983 984 985
    order_type = getattr(order, 'order_type')

    recipient_list = []
    total_registration_codes = None
986
    reg_code_info_list = []
987 988 989 990 991 992 993
    recipient_list.append(getattr(order.user, 'email'))
    if order_type == OrderTypes.BUSINESS:
        if order.company_contact_email:
            recipient_list.append(order.company_contact_email)
        if order.recipient_email:
            recipient_list.append(order.recipient_email)

994 995 996 997 998 999 1000 1001 1002 1003 1004 1005
        for __, course in shoppingcart_items:
            course_registration_codes = CourseRegistrationCode.objects.filter(order=order, course_id=course.id)
            total_registration_codes = course_registration_codes.count()
            for course_registration_code in course_registration_codes:
                reg_code_info_list.append({
                    'course_name': course.display_name,
                    'redemption_url': reverse('register_code_redemption', args=[course_registration_code.code]),
                    'code': course_registration_code.code,
                    'is_redeemed': RegistrationCodeRedemption.objects.filter(
                        registration_code=course_registration_code).exists(),
                })

1006 1007
    appended_recipient_emails = ", ".join(recipient_list)

1008 1009
    context = {
        'order': order,
1010
        'shoppingcart_items': shoppingcart_items,
1011
        'any_refunds': any_refunds,
1012
        'instructions': instructions,
1013 1014 1015 1016
        'site_name': microsite.get_value('SITE_NAME', settings.SITE_NAME),
        'order_type': order_type,
        'appended_course_names': appended_course_names,
        'appended_recipient_emails': appended_recipient_emails,
1017 1018
        'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
        'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0],
1019
        'total_registration_codes': total_registration_codes,
1020
        'reg_code_info_list': reg_code_info_list,
1021
        'order_purchase_date': order.purchase_time.strftime("%B %d, %Y"),
1022
    }
1023

1024 1025
    # We want to have the ability to override the default receipt page when
    # there is only one item in the order.
1026 1027
    if order_items.count() == 1:
        receipt_template = order_items[0].single_item_receipt_template
1028
        context.update(order_items[0].single_item_receipt_context)
1029 1030

    return render_to_response(receipt_template, context)
1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051


def _can_download_report(user):
    """
    Tests if the user can download the payments report, based on membership in a group whose name is determined
     in settings.  If the group does not exist, denies all access
    """
    try:
        access_group = Group.objects.get(name=settings.PAYMENT_REPORT_GENERATOR_GROUP)
    except Group.DoesNotExist:
        return False
    return access_group in user.groups.all()


def _get_date_from_str(date_input):
    """
    Gets date from the date input string.  Lets the ValueError raised by invalid strings be processed by the caller
    """
    return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC)


Julia Hansbrough committed
1052
def _render_report_form(start_str, end_str, start_letter, end_letter, report_type, total_count_error=False, date_fmt_error=False):
1053 1054 1055 1056 1057 1058 1059 1060
    """
    Helper function that renders the purchase form.  Reduces repetition
    """
    context = {
        'total_count_error': total_count_error,
        'date_fmt_error': date_fmt_error,
        'start_date': start_str,
        'end_date': end_str,
Julia Hansbrough committed
1061 1062
        'start_letter': start_letter,
        'end_letter': end_letter,
Julia Hansbrough committed
1063
        'requested_report': report_type,
1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076
    }
    return render_to_response('shoppingcart/download_report.html', context)


@login_required
def csv_report(request):
    """
    Downloads csv reporting of orderitems
    """
    if not _can_download_report(request.user):
        return HttpResponseForbidden(_('You do not have permission to view this page.'))

    if request.method == 'POST':
Julia Hansbrough committed
1077 1078
        start_date = request.POST.get('start_date', '')
        end_date = request.POST.get('end_date', '')
Julia Hansbrough committed
1079 1080
        start_letter = request.POST.get('start_letter', '')
        end_letter = request.POST.get('end_letter', '')
Julia Hansbrough committed
1081
        report_type = request.POST.get('requested_report', '')
1082
        try:
Julia Hansbrough committed
1083 1084
            start_date = _get_date_from_str(start_date) + datetime.timedelta(days=0)
            end_date = _get_date_from_str(end_date) + datetime.timedelta(days=1)
1085 1086
        except ValueError:
            # Error case: there was a badly formatted user-input date string
Julia Hansbrough committed
1087
            return _render_report_form(start_date, end_date, start_letter, end_letter, report_type, date_fmt_error=True)
Julia Hansbrough committed
1088

Julia Hansbrough committed
1089 1090
        report = initialize_report(report_type, start_date, end_date, start_letter, end_letter)
        items = report.rows()
1091 1092 1093 1094

        response = HttpResponse(mimetype='text/csv')
        filename = "purchases_report_{}.csv".format(datetime.datetime.now(pytz.UTC).strftime("%Y-%m-%d-%H-%M-%S"))
        response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
Julia Hansbrough committed
1095
        report.write_csv(response)
1096 1097 1098 1099 1100
        return response

    elif request.method == 'GET':
        end_date = datetime.datetime.now(pytz.UTC)
        start_date = end_date - datetime.timedelta(days=30)
Julia Hansbrough committed
1101 1102 1103
        start_letter = ""
        end_letter = ""
        return _render_report_form(start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d"), start_letter, end_letter, report_type="")
1104 1105 1106

    else:
        return HttpResponseBadRequest("HTTP Method Not Supported")