class EnterpriseCouponViewSet(CouponViewSet): """ Coupon resource. """ pagination_class = DatatablesDefaultPagination def get_queryset(self): filter_kwargs = { 'product_class__name': COUPON_PRODUCT_CLASS_NAME, 'attributes__code': 'enterprise_customer_uuid', } enterprise_id = self.kwargs.get('enterprise_id') if enterprise_id: filter_kwargs['attribute_values__value_text'] = enterprise_id return Product.objects.filter(**filter_kwargs).distinct() def get_serializer_class(self): if self.action == 'list': return EnterpriseCouponListSerializer elif self.action == 'overview': return EnterpriseCouponOverviewListSerializer return CouponSerializer def validate_access_for_enterprise_switch(self, request_data): if not waffle.switch_is_active(ENTERPRISE_OFFERS_FOR_COUPONS_SWITCH): raise ValidationError( 'This endpoint will be available once the enterprise offers switch is on.' ) @staticmethod def send_codes_availability_email(site, email_address, enterprise_id, coupon_id): send_new_codes_notification_email(site, email_address, enterprise_id, coupon_id) def create_coupon_and_vouchers(self, cleaned_voucher_data): coupon_product = create_coupon_product_and_stockrecord( cleaned_voucher_data['title'], cleaned_voucher_data['category'], cleaned_voucher_data['partner'], cleaned_voucher_data['price']) vouchers = create_enterprise_vouchers( voucher_type=cleaned_voucher_data['voucher_type'], quantity=cleaned_voucher_data['quantity'], coupon_id=coupon_product.id, benefit_type=cleaned_voucher_data['benefit_type'], benefit_value=cleaned_voucher_data['benefit_value'], enterprise_customer=cleaned_voucher_data['enterprise_customer'], enterprise_customer_catalog=cleaned_voucher_data[ 'enterprise_customer_catalog'], max_uses=cleaned_voucher_data['max_uses'], email_domains=cleaned_voucher_data['email_domains'], site=self.request.site, end_datetime=cleaned_voucher_data['end_datetime'], start_datetime=cleaned_voucher_data['start_datetime'], code=cleaned_voucher_data['code'], name=cleaned_voucher_data['title']) attach_vouchers_to_coupon_product( coupon_product, vouchers, cleaned_voucher_data['note'], cleaned_voucher_data.get('notify_email'), cleaned_voucher_data['enterprise_customer']) return coupon_product def update(self, request, *args, **kwargs): """Update coupon depending on request data sent.""" try: self.validate_access_for_enterprise_switch(request.data) except ValidationError as error: logger.exception(error.message) raise serializers.ValidationError(error.message) return super(EnterpriseCouponViewSet, self).update(request, *args, **kwargs) def update_range_data(self, request_data, vouchers): # Since enterprise coupons do not have ranges, we bypass the range update logic entirely. pass def update_offer_data(self, request_data, vouchers, site): """ Remove all offers from the vouchers and add a new offer Arguments: request_data (dict): the request parameters sent via api. vouchers (list): the vouchers attached to this coupon to update. site (Site): the site for this request. """ benefit_value = request_data.get('benefit_value') enterprise_customer = request_data.get('enterprise_customer', {}).get('id', None) enterprise_catalog = request_data.get( 'enterprise_customer_catalog') or None max_uses = request_data.get('max_uses') email_domains = request_data.get('email_domains') # Validate max_uses if max_uses is not None: if vouchers.first().usage == Voucher.SINGLE_USE: log_message_and_raise_validation_error( 'Failed to update Coupon. ' 'max_global_applications field cannot be set for voucher type [{voucher_type}].' .format(voucher_type=Voucher.SINGLE_USE)) try: max_uses = int(max_uses) if max_uses < 1: raise ValueError except ValueError: raise ValidationError( 'max_global_applications field must be a positive number.') coupon_was_migrated = False for voucher in vouchers.all(): updated_enterprise_offer = update_voucher_with_enterprise_offer( offer=voucher.enterprise_offer, benefit_value=benefit_value, max_uses=max_uses, enterprise_customer=enterprise_customer, enterprise_catalog=enterprise_catalog, email_domains=email_domains, site=site, ) updated_orginal_offer = None if voucher.original_offer != voucher.enterprise_offer: coupon_was_migrated = True updated_orginal_offer = update_voucher_offer( offer=voucher.original_offer, benefit_value=benefit_value, max_uses=max_uses, email_domains=email_domains, site=site, ) voucher.offers.clear() voucher.offers.add(updated_enterprise_offer) if updated_orginal_offer: voucher.offers.add(updated_orginal_offer) if coupon_was_migrated: super(EnterpriseCouponViewSet, self).update_range_data(request_data, vouchers) @detail_route(url_path='codes', permission_classes=[IsAuthenticated]) @permission_required( 'enterprise.can_view_coupon', fn=lambda request, pk, format=None: get_enterprise_from_product(pk)) def codes(self, request, pk, format=None): # pylint: disable=unused-argument, redefined-builtin """ GET codes belong to a `coupon`. Response will looks like { results: [ { code: '1234-5678-90', assigned_to: 'Barry Allen', redemptions: { used: 1, total: 5, }, redeem_url: 'https://testserver.fake/coupons/offer/?code=1234-5678-90', }, ] } """ coupon = self.get_object() coupon_vouchers = coupon.attr.coupon_vouchers.vouchers.all() usage_type = coupon_vouchers.first().usage code_filter = request.query_params.get('code_filter') queryset = None serializer_class = None if not code_filter: raise serializers.ValidationError('code_filter must be specified') if code_filter == VOUCHER_NOT_ASSIGNED: queryset = self._get_not_assigned_usages(coupon_vouchers) serializer_class = NotAssignedCodeUsageSerializer elif code_filter == VOUCHER_NOT_REDEEMED: queryset = self._get_not_redeemed_usages(coupon_vouchers) serializer_class = NotRedeemedCodeUsageSerializer elif code_filter == VOUCHER_PARTIAL_REDEEMED: queryset = self._get_partial_redeemed_usages(coupon_vouchers) serializer_class = PartialRedeemedCodeUsageSerializer elif code_filter == VOUCHER_REDEEMED: queryset = self._get_redeemed_usages(coupon_vouchers) serializer_class = RedeemedCodeUsageSerializer if not serializer_class: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) if format is None: page = self.paginate_queryset(queryset) serializer = serializer_class(page, many=True, context={'usage_type': usage_type}) return self.get_paginated_response(serializer.data) serializer = serializer_class(queryset, many=True, context={'usage_type': usage_type}) return Response(serializer.data) def _get_not_assigned_usages(self, vouchers): """ Returns a queryset containing Vouchers with slots that have not been assigned. Unique Vouchers will be included in the final queryset for all types. """ vouchers_with_slots = [] for voucher in vouchers: slots_available = voucher.slots_available_for_assignment if slots_available == 0: continue vouchers_with_slots.append(voucher.id) return Voucher.objects.filter( id__in=vouchers_with_slots).values('code').order_by('code') def _get_not_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have no corresponding VoucherApplication are returned. """ unredeemed_assignments = [] for voucher in vouchers: users_having_usages = VoucherApplication.objects.filter( voucher=voucher).values_list('user__email', flat=True) assignments = voucher.enterprise_offer.offerassignment_set.filter( code=voucher.code, status__in=[ OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_BOUNCED, OFFER_ASSIGNMENT_EMAIL_PENDING ]).exclude(user_email__in=users_having_usages) if assignments.count() == 0: continue unredeemed_assignments.extend( assignments.values_list('id', flat=True)) return OfferAssignment.objects.filter( id__in=unredeemed_assignments).values( 'code', 'user_email').order_by('user_email').distinct() def _get_partial_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have at least one corresponding VoucherApplication are returned. """ # There are no partially redeemed SINGLE_USE codes, so return the empty queryset. if vouchers.first().usage == Voucher.SINGLE_USE: return OfferAssignment.objects.none() parially_redeemed_assignments = [] for voucher in vouchers: users_having_usages = VoucherApplication.objects.filter( voucher=voucher).values_list('user__email', flat=True) assignments = voucher.enterprise_offer.offerassignment_set.filter( code=voucher.code, status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], user_email__in=users_having_usages) if assignments.count() == 0: continue parially_redeemed_assignments.append(assignments.first().id) return OfferAssignment.objects.filter( id__in=parially_redeemed_assignments).values( 'code', 'user_email').order_by('user_email') def _get_redeemed_usages(self, vouchers): """ Returns a queryset containing unique voucher.code and user.email pairs from VoucherApplications. Only code and email pairs that have no corresponding active OfferAssignments are returned. """ voucher_applications = VoucherApplication.objects.filter( voucher__in=vouchers) redeemed_voucher_application_ids = [] for voucher_application in voucher_applications: unredeemed_voucher_assignments = OfferAssignment.objects.filter( code=voucher_application.voucher.code, user_email=voucher_application.user.email, status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING]) if unredeemed_voucher_assignments.count() == 0: redeemed_voucher_application_ids.append(voucher_application.id) return VoucherApplication.objects.filter( id__in=redeemed_voucher_application_ids).values( 'voucher__code', 'user__email').distinct().order_by('user__email') @list_route(url_path=r'(?P<enterprise_id>.+)/overview', permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_view_coupon', fn=lambda request, enterprise_id: enterprise_id) def overview(self, request, enterprise_id): # pylint: disable=unused-argument """ Overview of Enterprise coupons. Returns the following data: - Coupon ID - Coupon name. - Max number of codes available (Maximum coupon usage). - Number of codes. - Redemption count. - Valid from. - Valid end. """ enterprise_coupons = self.get_queryset() coupon_id = self.request.query_params.get('coupon_id', None) if coupon_id is not None: coupon = get_object_or_404(enterprise_coupons, id=coupon_id) serializer = self.get_serializer(coupon) return Response(serializer.data, status=status.HTTP_200_OK) else: page = self.paginate_queryset(enterprise_coupons) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) @detail_route(methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def assign(self, request, pk): # pylint: disable=unused-argument """ Assign users by email to codes within the Coupon. """ coupon = self.get_object() template = request.data.pop('template') serializer = CouponCodeAssignmentSerializer(data=request.data, context={ 'coupon': coupon, 'template': template }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @detail_route(methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def revoke(self, request, pk): # pylint: disable=unused-argument """ Revoke users by email from codes within the Coupon. """ coupon = self.get_object() email_template = request.data.pop('template', None) serializer = CouponCodeRevokeSerializer( data=request.data.get('assignments'), many=True, context={ 'coupon': coupon, 'template': email_template }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @detail_route(methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def remind(self, request, pk): # pylint: disable=unused-argument """ Remind users of pending offer assignments by email. """ coupon = self.get_object() email_template = request.data.pop('template', None) if not email_template: log_message_and_raise_validation_error( str('Template is required.')) if request.data.get('assignments'): assignments = request.data.get('assignments') else: # If no assignment is passed, send reminder to all assignments associated with the coupon. vouchers = coupon.attr.coupon_vouchers.vouchers.all() code_filter = request.data.get('code_filter') if not code_filter: raise serializers.ValidationError( 'code_filter must be specified') if code_filter == VOUCHER_NOT_REDEEMED: assignment_usages = self._get_not_redeemed_usages(vouchers) elif code_filter == VOUCHER_PARTIAL_REDEEMED: assignment_usages = self._get_partial_redeemed_usages(vouchers) else: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) assignments = [{ 'code': assignment['code'], 'email': assignment['user_email'] } for assignment in assignment_usages] serializer = CouponCodeRemindSerializer(data=assignments, many=True, context={ 'coupon': coupon, 'template': email_template }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class EnterpriseCouponViewSet(CouponViewSet): """ Coupon resource. """ pagination_class = DatatablesDefaultPagination def get_queryset(self): filter_kwargs = { 'product_class__name': COUPON_PRODUCT_CLASS_NAME, 'attributes__code': 'enterprise_customer_uuid', } enterprise_id = self.kwargs.get('enterprise_id') if enterprise_id: filter_kwargs['attribute_values__value_text'] = enterprise_id coupons = Product.objects.filter(**filter_kwargs) if self.request.query_params.get('filter') == 'active': active_coupon = ~Q(attributes__code='inactive') | Q( attributes__code='inactive', attribute_values__value_boolean=False) coupons = coupons.filter(active_coupon) return coupons.distinct() def get_serializer_class(self): if self.action == 'list': return EnterpriseCouponListSerializer if self.action == 'overview': return EnterpriseCouponOverviewListSerializer return CouponSerializer def validate_access_for_enterprise(self, request_data): # Bypass old-style coupons in enterprise view. pass @staticmethod def send_codes_availability_email(site, email_address, enterprise_id, coupon_id): send_new_codes_notification_email(site, email_address, enterprise_id, coupon_id) def create_coupon_and_vouchers(self, cleaned_voucher_data): coupon_product = create_coupon_product_and_stockrecord( cleaned_voucher_data['title'], cleaned_voucher_data['category'], cleaned_voucher_data['partner'], cleaned_voucher_data['price']) vouchers = create_enterprise_vouchers( voucher_type=cleaned_voucher_data['voucher_type'], quantity=cleaned_voucher_data['quantity'], coupon_id=coupon_product.id, benefit_type=cleaned_voucher_data['benefit_type'], benefit_value=cleaned_voucher_data['benefit_value'], enterprise_customer=cleaned_voucher_data['enterprise_customer'], enterprise_customer_catalog=cleaned_voucher_data[ 'enterprise_customer_catalog'], max_uses=cleaned_voucher_data['max_uses'], email_domains=cleaned_voucher_data['email_domains'], site=self.request.site, end_datetime=cleaned_voucher_data['end_datetime'], start_datetime=cleaned_voucher_data['start_datetime'], code=cleaned_voucher_data['code'], name=cleaned_voucher_data['title']) attach_vouchers_to_coupon_product( coupon_product, vouchers, cleaned_voucher_data['note'], cleaned_voucher_data.get('notify_email'), cleaned_voucher_data['enterprise_customer'], cleaned_voucher_data['sales_force_id']) attach_or_update_contract_metadata_on_coupon( coupon_product, discount_type=cleaned_voucher_data['contract_discount_type'], discount_value=cleaned_voucher_data['contract_discount_value'], amount_paid=cleaned_voucher_data['prepaid_invoice_amount'], ) return coupon_product def update_range_data(self, request_data, vouchers): # Since enterprise coupons do not have ranges, we bypass the range update logic entirely. pass def update_offer_data(self, request_data, vouchers, site): """ Remove all offers from the vouchers and add a new offer Arguments: request_data (dict): the request parameters sent via api. vouchers (list): the vouchers attached to this coupon to update. site (Site): the site for this request. """ benefit_value = request_data.get('benefit_value') enterprise_customer = request_data.get('enterprise_customer', {}).get('id', None) enterprise_catalog = request_data.get( 'enterprise_customer_catalog') or None max_uses = request_data.get('max_uses') email_domains = request_data.get('email_domains') # Validate max_uses if max_uses is not None: if vouchers.first().usage == Voucher.SINGLE_USE: log_message_and_raise_validation_error( 'Failed to update Coupon. ' 'max_global_applications field cannot be set for voucher type [{voucher_type}].' .format(voucher_type=Voucher.SINGLE_USE)) try: max_uses = int(max_uses) if max_uses < 1: raise ValueError except ValueError: raise ValidationError( 'max_global_applications field must be a positive number.') coupon_was_migrated = False for voucher in vouchers.all(): updated_enterprise_offer = update_voucher_with_enterprise_offer( offer=voucher.enterprise_offer, benefit_value=benefit_value, max_uses=max_uses, enterprise_customer=enterprise_customer, enterprise_catalog=enterprise_catalog, email_domains=email_domains, site=site, ) updated_orginal_offer = None if voucher.original_offer != voucher.enterprise_offer: coupon_was_migrated = True updated_orginal_offer = update_voucher_offer( offer=voucher.original_offer, benefit_value=benefit_value, max_uses=max_uses, email_domains=email_domains, site=site, ) voucher.offers.clear() voucher.offers.add(updated_enterprise_offer) if updated_orginal_offer: voucher.offers.add(updated_orginal_offer) update_assignments_for_multi_use_per_customer(voucher) if coupon_was_migrated: super(EnterpriseCouponViewSet, self).update_range_data(request_data, vouchers) @action(detail=True, url_path='codes', permission_classes=[IsAuthenticated]) @permission_required( 'enterprise.can_view_coupon', fn=lambda request, pk, format=None: get_enterprise_from_product(pk)) def codes(self, request, pk, format=None): # pylint: disable=unused-argument, redefined-builtin """ GET codes belong to a `coupon`. Response will looks like { results: [ { code: '1234-5678-90', assigned_to: 'Barry Allen', redemptions: { used: 1, total: 5, }, redeem_url: 'https://testserver.fake/coupons/offer/?code=1234-5678-90', }, ] } """ coupon = self.get_object() coupon_vouchers = coupon.attr.coupon_vouchers.vouchers.all() usage_type = coupon_vouchers.first().usage code_filter = request.query_params.get('code_filter') queryset = None serializer_class = None if not code_filter: raise serializers.ValidationError('code_filter must be specified') if code_filter == VOUCHER_NOT_ASSIGNED: queryset = self._get_not_assigned_usages(coupon_vouchers) serializer_class = NotAssignedCodeUsageSerializer elif code_filter == VOUCHER_NOT_REDEEMED: queryset = self._get_not_redeemed_usages(coupon_vouchers) serializer_class = NotRedeemedCodeUsageSerializer elif code_filter == VOUCHER_PARTIAL_REDEEMED: queryset = self._get_partial_redeemed_usages(coupon_vouchers) serializer_class = PartialRedeemedCodeUsageSerializer elif code_filter == VOUCHER_REDEEMED: queryset = self._get_redeemed_usages(coupon_vouchers) serializer_class = RedeemedCodeUsageSerializer if not serializer_class: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) if format is None: page = self.paginate_queryset(queryset) serializer = serializer_class(page, many=True, context={'usage_type': usage_type}) return self.get_paginated_response(serializer.data) serializer = serializer_class(queryset, many=True, context={'usage_type': usage_type}) return Response(serializer.data) def _get_not_assigned_usages(self, vouchers): """ Returns a queryset containing Vouchers with slots that have not been assigned. Unique Vouchers will be included in the final queryset for all types. """ # filter out all vouchers that never been assigned to anyone voucher_offer_assignments = OfferAssignment.objects.filter( code__in=vouchers.values_list('code', flat=True)).exclude( status=OFFER_ASSIGNMENT_REVOKED) applications = VoucherApplication.objects.filter(voucher__in=vouchers) vouchers_never_assigned = vouchers.exclude( code__in=voucher_offer_assignments.values_list('code', flat=True) ).exclude( code__in=applications.values_list('voucher__code', flat=True)) # Now filter out vouchers that can be assign to multiple customers but not fully assigned offer_max_uses = vouchers.first( ).enterprise_offer.max_global_applications or OFFER_MAX_USES_DEFAULT offer_assignments_subquery = OfferAssignment.objects.filter( code=OuterRef('code')).exclude( status__in=[OFFER_REDEEMED, OFFER_ASSIGNMENT_REVOKED ]).order_by().values('code').annotate( count=Count('*')).values('count') partially_assigned_multi_customer_vouchers = vouchers.filter( usage__in=[Voucher.MULTI_USE, Voucher.ONCE_PER_CUSTOMER]).annotate( num_assignments=Subquery(queryset=offer_assignments_subquery, output_field=PositiveIntegerField()) ).annotate(usage_count=ExpressionWrapper( F('num_orders') + F('num_assignments'), output_field=PositiveIntegerField())).filter( usage_count__lt=offer_max_uses) # take union of never assigned and partially assigned assignable_vouchers = partially_assigned_multi_customer_vouchers.union( vouchers_never_assigned) return assignable_vouchers.values('code').order_by('code') def _get_not_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have no corresponding VoucherApplication are returned. """ users_having_usages = VoucherApplication.objects.filter( voucher__in=vouchers).values_list('user__email', flat=True) return OfferAssignment.objects.filter( code__in=vouchers.values_list('code', flat=True), status__in=[ OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_BOUNCED, OFFER_ASSIGNMENT_EMAIL_PENDING ]).exclude(user_email__in=users_having_usages).values( 'code', 'user_email').order_by('user_email').distinct() def _get_partial_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have at least one corresponding VoucherApplication are returned. """ # There are no partially redeemed SINGLE_USE codes, so return the empty queryset. if vouchers.first().usage == Voucher.SINGLE_USE: return OfferAssignment.objects.none() users_having_usages = VoucherApplication.objects.filter( voucher__in=vouchers).values_list('user__email', flat=True) return OfferAssignment.objects.filter( code__in=vouchers.values_list('code', flat=True), status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], user_email__in=users_having_usages).values( 'code', 'user_email').order_by('user_email').distinct() def _get_redeemed_usages(self, vouchers): """ Returns a queryset containing unique voucher.code and user.email pairs from VoucherApplications. Only code and email pairs that have no corresponding active OfferAssignments are returned. """ vouchers_applications = VoucherApplication.objects.filter( voucher__in=vouchers) unredeemed_voucher_assignments = OfferAssignment.objects.filter( code__in=vouchers_applications.values_list('voucher__code', flat=True), user_email__in=vouchers_applications.values_list('user__email', flat=True), status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING]) return vouchers_applications.exclude( voucher__code__in=unredeemed_voucher_assignments.values_list( 'code', flat=True), user__email__in=unredeemed_voucher_assignments.values_list( 'user_email', flat=True)).values( 'voucher__code', 'user__email').order_by('user__email').distinct() @action(detail=False, url_path=r'(?P<enterprise_id>.+)/search', permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_view_coupon', fn=lambda request, enterprise_id: enterprise_id) def search(self, request, enterprise_id): # pylint: disable=unused-argument """ Return coupon information based on query param values provided. """ user_email = self.request.query_params.get('user_email', None) voucher_code = self.request.query_params.get('voucher_code', None) if not (user_email or voucher_code): raise Http404("No search query parameter provided.") try: user = User.objects.get(email=user_email) except ObjectDoesNotExist: user = None enterprise_vouchers = self._collect_enterprise_vouchers_for_search( user_email, user, voucher_code) redemptions_and_assignments = self._form_search_response_data_from_vouchers( enterprise_vouchers, user_email, user, ) page = self.paginate_queryset(redemptions_and_assignments) serializer = EnterpriseCouponSearchSerializer( page, many=True, ) return self.get_paginated_response(serializer.data) def _collect_enterprise_vouchers_for_search(self, user_email, user, voucher_code): """ Gather vouchers based on offerAssignments and voucherApplications associated with the user (and enterprise specified in request url) Returns queryset of Voucher objects, with related tables prefetched. """ # We want vouchers associated with this enterprise. Note: # self.get_queryset() here filters (coupon) products out for # the enterprise_id value handed to this view enterprise_vouchers = Voucher.objects.filter( coupon_vouchers__coupon__in=self.get_queryset()) # When search is made by code, only one voucher will exist so return it directly if not user_email: voucher = enterprise_vouchers.filter(code=voucher_code) return voucher # We want vouchers with OfferAssignments related to the user email # that do not have a voucher_application (aka they have been assigned # but not redeemed) no_voucher_application = Q(voucher_application__isnull=True) offer_assignments = OfferAssignment.objects.filter( no_voucher_application, user_email=user_email, status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], ) vouchers_from_offer_assignments = Q( offers__offerassignment__in=offer_assignments) # We also want vouchers with VoucherApplications related to the user # but only if the user exists (there is a chance it does not, as code # assignment only requires an email, and not an account on the system) if user is not None: voucher_applications = VoucherApplication.objects.filter(user=user) vouchers_from_voucher_applications = Q( applications__in=voucher_applications) enterprise_vouchers = enterprise_vouchers.filter( vouchers_from_offer_assignments | vouchers_from_voucher_applications) else: enterprise_vouchers = enterprise_vouchers.filter( vouchers_from_offer_assignments) return enterprise_vouchers.distinct().prefetch_related( 'coupon_vouchers__coupon', 'applications', 'applications__order__lines__product__course', ) def _form_search_response_data_from_vouchers(self, vouchers, user_email, user): """ Build a list of dictionaries that contains the relevant information for each voucher_application (redemption) or offer_assignment (assignment). Returns a list of dictionaries to be handed to the serializer for construction of pagination. """ def _prepare_redemption_data(coupon_data, offer_assignment=None): """ Prepares redemption data for the received voucher in coupon_data """ redemption_data = dict(coupon_data) redemption_data['course_title'] = None redemption_data['course_key'] = None redemption_data['redeemed_date'] = None redemption_data[ 'user_email'] = offer_assignment.user_email if offer_assignment else None redemptions_and_assignments.append(redemption_data) redemptions_and_assignments = [] for voucher in vouchers: coupon_data = { 'coupon_id': voucher.coupon_vouchers.first().coupon.id, 'coupon_name': voucher.coupon_vouchers.first().coupon.title, 'code': voucher.code, 'voucher_id': voucher.id, } if user is not None: for application in voucher.applications.filter( user_id=user.id): redemption_data = dict(coupon_data) redemption_data[ 'course_title'] = application.order.lines.first( ).product.course.name redemption_data[ 'course_key'] = application.order.lines.first( ).product.course.id redemption_data['redeemed_date'] = application.date_created redemptions_and_assignments.append(redemption_data) no_voucher_application = Q(voucher_application__isnull=True) filter_kwargs = { 'code': voucher.code, 'status__in': [OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], } if user_email: filter_kwargs['user_email'] = user_email offer_assignments = OfferAssignment.objects.filter( no_voucher_application, **filter_kwargs).distinct() coupon_data['is_assigned'] = offer_assignments.count() # For the case when an unassigned voucher code is searched if offer_assignments.count() == 0: if not user_email: _prepare_redemption_data(coupon_data) else: for offer_assignment in offer_assignments: _prepare_redemption_data(coupon_data, offer_assignment) return redemptions_and_assignments @action(detail=False, url_path=r'(?P<enterprise_id>.+)/overview', permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_view_coupon', fn=lambda request, enterprise_id: enterprise_id) def overview(self, request, enterprise_id): # pylint: disable=unused-argument """ Overview of Enterprise coupons. Returns the following data: - Coupon ID - Coupon name. - Max number of codes available (Maximum coupon usage). - Number of codes. - Redemption count. - Valid from. - Valid end. """ enterprise_coupons = self.get_queryset() coupon_id = self.request.query_params.get('coupon_id', None) if coupon_id is not None: coupon = get_object_or_404(enterprise_coupons, id=coupon_id) serializer = self.get_serializer(coupon) return Response(serializer.data, status=status.HTTP_200_OK) page = self.paginate_queryset(enterprise_coupons) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) def _validate_coupon_availablity(self, coupon, message): """ Raise ValidationError with specified message if coupon is not available. """ if not is_coupon_available(coupon): raise DRFValidationError({'error': message}) def _validate_email_fields(self, greeting, closing): """ Raise ValidationError if greeting and/or closing is above the allowed limit. """ errors = {} max_field_limit = OFFER_ASSIGNMENT_EMAIL_TEMPLATE_FIELD_LIMIT if len(greeting) > max_field_limit: errors[ 'email_greeting'] = 'Email greeting must be {} characters or less'.format( max_field_limit) if len(closing) > max_field_limit: errors[ 'email_closing'] = 'Email closing must be {} characters or less'.format( max_field_limit) if errors: raise DRFValidationError({'error': errors}) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def assign(self, request, pk): # pylint: disable=unused-argument """ Assign users by email to codes within the Coupon. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code assignment') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') self._validate_email_fields(greeting, closing) serializer = CouponCodeAssignmentSerializer(data=request.data, context={ 'coupon': coupon, 'greeting': greeting, 'closing': closing }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def revoke(self, request, pk): # pylint: disable=unused-argument """ Revoke users by email from codes within the Coupon. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code revoke') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') self._validate_email_fields(greeting, closing) serializer = CouponCodeRevokeSerializer( data=request.data.get('assignments'), many=True, context={ 'coupon': coupon, 'greeting': greeting, 'closing': closing }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def remind(self, request, pk): # pylint: disable=unused-argument """ Remind users of pending offer assignments by email. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code remind') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') self._validate_email_fields(greeting, closing) if request.data.get('assignments'): assignments = request.data.get('assignments') else: # If no assignment is passed, send reminder to all assignments associated with the coupon. vouchers = coupon.attr.coupon_vouchers.vouchers.all() code_filter = request.data.get('code_filter') if not code_filter: raise serializers.ValidationError( 'code_filter must be specified') if code_filter == VOUCHER_NOT_REDEEMED: assignment_usages = self._get_not_redeemed_usages(vouchers) elif code_filter == VOUCHER_PARTIAL_REDEEMED: assignment_usages = self._get_partial_redeemed_usages(vouchers) else: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) assignments = [{ 'code': assignment['code'], 'email': assignment['user_email'] } for assignment in assignment_usages] serializer = CouponCodeRemindSerializer(data=assignments, many=True, context={ 'coupon': coupon, 'greeting': greeting, 'closing': closing }) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class EnterpriseCouponViewSet(CouponViewSet): """ Coupon resource. """ pagination_class = DatatablesDefaultPagination def get_queryset(self): filter_kwargs = { 'product_class__name': COUPON_PRODUCT_CLASS_NAME, 'attributes__code': 'enterprise_customer_uuid', } enterprise_id = self.kwargs.get('enterprise_id') if enterprise_id: filter_kwargs['attribute_values__value_text'] = enterprise_id coupons = Product.objects.filter(**filter_kwargs) if self.request.query_params.get('filter') == 'active': active_coupon = ~Q(attributes__code='inactive') | Q( attributes__code='inactive', attribute_values__value_boolean=False) coupons = coupons.filter(active_coupon) return coupons.distinct() def get_serializer_class(self): if self.action == 'list': return EnterpriseCouponListSerializer if self.action == 'overview': return EnterpriseCouponOverviewListSerializer return CouponSerializer def validate_access_for_enterprise(self, request_data): # Bypass old-style coupons in enterprise view. pass @staticmethod def send_codes_availability_email(site, email_address, enterprise_id, coupon_id): send_new_codes_notification_email(site, email_address, enterprise_id, coupon_id) def create_coupon_and_vouchers(self, cleaned_voucher_data): coupon_product = create_coupon_product_and_stockrecord( cleaned_voucher_data['title'], cleaned_voucher_data['category'], cleaned_voucher_data['partner'], cleaned_voucher_data['price']) vouchers = create_enterprise_vouchers( voucher_type=cleaned_voucher_data['voucher_type'], quantity=cleaned_voucher_data['quantity'], coupon_id=coupon_product.id, benefit_type=cleaned_voucher_data['benefit_type'], benefit_value=cleaned_voucher_data['benefit_value'], enterprise_customer=cleaned_voucher_data['enterprise_customer'], enterprise_customer_catalog=cleaned_voucher_data[ 'enterprise_customer_catalog'], max_uses=cleaned_voucher_data['max_uses'], email_domains=cleaned_voucher_data['email_domains'], site=self.request.site, end_datetime=cleaned_voucher_data['end_datetime'], start_datetime=cleaned_voucher_data['start_datetime'], code=cleaned_voucher_data['code'], name=cleaned_voucher_data['title']) attach_vouchers_to_coupon_product( coupon_product, vouchers, cleaned_voucher_data['note'], cleaned_voucher_data.get('notify_email'), cleaned_voucher_data['enterprise_customer'], cleaned_voucher_data['sales_force_id']) attach_or_update_contract_metadata_on_coupon( coupon_product, discount_type=cleaned_voucher_data['contract_discount_type'], discount_value=cleaned_voucher_data['contract_discount_value'], amount_paid=cleaned_voucher_data['prepaid_invoice_amount'], ) return coupon_product def update_range_data(self, request_data, vouchers): # Since enterprise coupons do not have ranges, we bypass the range update logic entirely. pass def is_offer_data_updated(self, benefit_value, enterprise_customer, enterprise_catalog, max_uses, email_domains): """ Compares request data with the existing coupon data to determine if the offer in voucher needs to be updated.""" existing_data = self.get_serializer(self.get_object()).data existing_benefit_value = existing_data.get('benefit_value') existing_enterprise_customer = existing_data.get( 'enterprise_customer', {}).get('id') existing_enterprise_catalog = existing_data.get( 'enterprise_customer_catalog') existing_max_uses = existing_data.get('max_uses') existing_email_domains = existing_data.get('email_domains') if benefit_value == existing_benefit_value \ and enterprise_customer == str(existing_enterprise_customer) \ and enterprise_catalog == str(existing_enterprise_catalog) \ and max_uses == existing_max_uses \ and email_domains == existing_email_domains: # nothing changed related to the offers. return False return True def update_offer_data(self, request_data, vouchers, site): """ Remove all offers from the vouchers and add a new offer Arguments: request_data (dict): the request parameters sent via api. vouchers (list): the vouchers attached to this coupon to update. site (Site): the site for this request. """ benefit_value = request_data.get('benefit_value') enterprise_customer = request_data.get('enterprise_customer', {}).get('id', None) enterprise_catalog = request_data.get( 'enterprise_customer_catalog') or None max_uses = request_data.get('max_uses') email_domains = request_data.get('email_domains') if not self.is_offer_data_updated(benefit_value, enterprise_customer, enterprise_catalog, max_uses, email_domains): # Offer data does not need to be updated for current request return coupon_was_migrated = False for voucher in vouchers: updated_enterprise_offer = update_voucher_with_enterprise_offer( offer=voucher.enterprise_offer, benefit_value=benefit_value, max_uses=max_uses, enterprise_customer=enterprise_customer, enterprise_catalog=enterprise_catalog, email_domains=email_domains, site=site, ) updated_orginal_offer = None if voucher.original_offer != voucher.enterprise_offer: coupon_was_migrated = True updated_orginal_offer = update_voucher_offer( offer=voucher.original_offer, benefit_value=benefit_value, max_uses=max_uses, email_domains=email_domains, site=site, ) voucher.offers.clear() voucher.offers.add(updated_enterprise_offer) if updated_orginal_offer: voucher.offers.add(updated_orginal_offer) update_assignments_for_multi_use_per_customer(voucher) if coupon_was_migrated: super(EnterpriseCouponViewSet, self).update_range_data(request_data, vouchers) @action(detail=True, url_path='codes', permission_classes=[IsAuthenticated]) @permission_required( 'enterprise.can_view_coupon', fn=lambda request, pk, format=None: get_enterprise_from_product(pk)) def codes(self, request, pk, format=None): # pylint: disable=unused-argument, redefined-builtin """ GET codes belong to a `coupon`. Response will looks like { results: [ { code: '1234-5678-90', assigned_to: 'Barry Allen', redemptions: { used: 1, total: 5, }, redeem_url: 'https://testserver.fake/coupons/offer/?code=1234-5678-90', }, ] } """ coupon = self.get_object() coupon_vouchers = coupon.attr.coupon_vouchers.vouchers.all() usage_type = coupon_vouchers.first().usage code_filter = request.query_params.get('code_filter') visibility_filter = request.query_params.get('visibility_filter') queryset = None serializer_class = None if not code_filter: raise serializers.ValidationError('code_filter must be specified') if code_filter == VOUCHER_NOT_ASSIGNED: queryset = self._get_not_assigned_usages(coupon_vouchers) serializer_class = NotAssignedCodeUsageSerializer elif code_filter == VOUCHER_NOT_REDEEMED: queryset = self._get_not_redeemed_usages(coupon_vouchers) serializer_class = NotRedeemedCodeUsageSerializer elif code_filter == VOUCHER_PARTIAL_REDEEMED: queryset = self._get_partial_redeemed_usages(coupon_vouchers) serializer_class = PartialRedeemedCodeUsageSerializer elif code_filter == VOUCHER_REDEEMED: queryset = self._get_redeemed_usages(coupon_vouchers) serializer_class = RedeemedCodeUsageSerializer if not serializer_class: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) if visibility_filter == VOUCHER_IS_PUBLIC: queryset = queryset.filter(is_public=True) elif visibility_filter == VOUCHER_IS_PRIVATE: queryset = queryset.filter(is_public=False) elif visibility_filter is not None: raise serializers.ValidationError( "visibility_filter must be specified as 'public' or 'private' received: {}" .format(visibility_filter)) if format is None: page = self.paginate_queryset(queryset) serializer = serializer_class(page, many=True, context={'usage_type': usage_type}) return self.get_paginated_response(serializer.data) serializer = serializer_class(queryset, many=True, context={'usage_type': usage_type}) return Response(serializer.data) def _get_not_assigned_usages(self, vouchers): """ Returns a queryset containing Vouchers with slots that have not been assigned. Unique Vouchers will be included in the final queryset for all types. """ prefetch_related_objects(vouchers, 'offers', 'offers__condition', 'offers__offerassignment_set') vouchers_with_slots = [] for voucher in vouchers: slots_available = voucher.slots_available_for_assignment if slots_available == 0: continue vouchers_with_slots.append(voucher.id) return Voucher.objects.filter( id__in=vouchers_with_slots).values('code').order_by('code') def _get_not_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have no corresponding VoucherApplication are returned. """ prefetch_related_objects(vouchers, 'applications', 'applications__user', 'offers', 'offers__condition', 'offers__offerassignment_set') not_redeemed_assignments = [] for voucher in vouchers: assignments = voucher.not_redeemed_assignment_ids if assignments: not_redeemed_assignments.extend(assignments) return OfferAssignment.objects.filter( id__in=not_redeemed_assignments).values( 'code', 'user_email').order_by('user_email').distinct() def _get_partial_redeemed_usages(self, vouchers): """ Returns a queryset containing unique code and user_email pairs from OfferAssignments. Only code and user_email pairs that have at least one corresponding VoucherApplication are returned. """ # There are no partially redeemed SINGLE_USE codes, so return the empty queryset. if vouchers.first().usage == Voucher.SINGLE_USE: return OfferAssignment.objects.none() users_having_usages = VoucherApplication.objects.filter( voucher__in=vouchers).values_list('user__email', flat=True) return OfferAssignment.objects.filter( code__in=vouchers.values_list('code', flat=True), status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], user_email__in=users_having_usages).values( 'code', 'user_email').order_by('user_email').distinct() def _get_redeemed_usages(self, vouchers): """ Returns a queryset containing unique voucher.code and user.email pairs from VoucherApplications. Only code and email pairs that have no corresponding active OfferAssignments are returned. """ vouchers_applications = VoucherApplication.objects.filter( voucher__in=vouchers) unredeemed_voucher_assignments = OfferAssignment.objects.filter( code__in=vouchers_applications.values_list('voucher__code', flat=True), user_email__in=vouchers_applications.values_list('user__email', flat=True), status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING]) return vouchers_applications.exclude( voucher__code__in=unredeemed_voucher_assignments.values_list( 'code', flat=True), user__email__in=unredeemed_voucher_assignments.values_list( 'user_email', flat=True)).values( 'voucher__code', 'user__email').order_by('user__email').distinct() @action(detail=False, url_path=r'(?P<enterprise_id>.+)/search', permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_view_coupon', fn=lambda request, enterprise_id: enterprise_id) def search(self, request, enterprise_id): # pylint: disable=unused-argument """ Return coupon information based on query param values provided. """ user_email = self.request.query_params.get('user_email', None) voucher_code = self.request.query_params.get('voucher_code', None) if not (user_email or voucher_code): raise Http404("No search query parameter provided.") try: user = User.objects.get(email=user_email) except ObjectDoesNotExist: user = None enterprise_vouchers = self._collect_enterprise_vouchers_for_search( user_email, user, voucher_code) redemptions_and_assignments = self._form_search_response_data_from_vouchers( enterprise_vouchers, user_email, user, ) page = self.paginate_queryset(redemptions_and_assignments) serializer = EnterpriseCouponSearchSerializer( page, many=True, ) return self.get_paginated_response(serializer.data) def _collect_enterprise_vouchers_for_search(self, user_email, user, voucher_code): """ Gather vouchers based on offerAssignments and voucherApplications associated with the user (and enterprise specified in request url) Returns queryset of Voucher objects, with related tables prefetched. """ # We want vouchers associated with this enterprise. Note: # self.get_queryset() here filters (coupon) products out for # the enterprise_id value handed to this view enterprise_vouchers = Voucher.objects.filter( coupon_vouchers__coupon__in=self.get_queryset()) # When search is made by code, only one voucher will exist so return it directly if not user_email: voucher = enterprise_vouchers.filter(code=voucher_code) return voucher # We want vouchers with OfferAssignments related to the user email # that do not have a voucher_application (aka they have been assigned # but not redeemed) no_voucher_application = Q(voucher_application__isnull=True) offer_assignments = OfferAssignment.objects.filter( no_voucher_application, user_email=user_email, status__in=[OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING], ) vouchers_from_offer_assignments = Q( offers__offerassignment__in=offer_assignments) # We also want vouchers with VoucherApplications related to the user # but only if the user exists (there is a chance it does not, as code # assignment only requires an email, and not an account on the system) if user is not None: voucher_applications = VoucherApplication.objects.filter(user=user) vouchers_from_voucher_applications = Q( applications__in=voucher_applications) enterprise_vouchers = enterprise_vouchers.filter( vouchers_from_offer_assignments | vouchers_from_voucher_applications) else: enterprise_vouchers = enterprise_vouchers.filter( vouchers_from_offer_assignments) return enterprise_vouchers.distinct().prefetch_related( 'coupon_vouchers__coupon', 'applications', 'applications__order__lines__product__course', ) def _form_search_response_data_from_vouchers(self, vouchers, user_email, user): """ Build a list of dictionaries that contains the relevant information for each voucher_application (redemption) or offer_assignment (assignment). Returns a list of dictionaries to be handed to the serializer for construction of pagination. """ def _prepare_redemption_data(coupon_data, offer_assignment=None): """ Prepares redemption data for the received voucher in coupon_data """ redemption_data = dict(coupon_data) redemption_data['course_title'] = None redemption_data['course_key'] = None redemption_data['redeemed_date'] = None redemption_data[ 'user_email'] = offer_assignment.user_email if offer_assignment else None redemptions_and_assignments.append(redemption_data) redemptions_and_assignments = [] prefetch_related_objects(vouchers, 'applications', 'coupon_vouchers', 'coupon_vouchers__coupon', 'offers', 'offers__condition', 'offers__offerassignment_set') for voucher in vouchers: coupon_vouchers = voucher.coupon_vouchers.all() coupon_voucher = coupon_vouchers[0] coupon_data = { 'coupon_id': coupon_voucher.coupon.id, 'coupon_name': coupon_voucher.coupon.title, 'code': voucher.code, 'voucher_id': voucher.id, } if user is not None: for application in voucher.applications.all(): if application.user.id == user.id: line = application.order.lines.first() redemption_data = dict(coupon_data) redemption_data[ 'course_title'] = line.product.course.name redemption_data['course_key'] = line.product.course.id redemption_data[ 'redeemed_date'] = application.date_created redemptions_and_assignments.append(redemption_data) offer = voucher and voucher.enterprise_offer all_offer_assignments = offer.offerassignment_set.all() offer_assignments = [] for assignment in all_offer_assignments: if (assignment.voucher_application is None and assignment.status in [OFFER_ASSIGNED, OFFER_ASSIGNMENT_EMAIL_PENDING] and assignment.code == voucher.code and (assignment.user_email == user_email if user_email else True)): offer_assignments.append(assignment) coupon_data['is_assigned'] = len(offer_assignments) # For the case when an unassigned voucher code is searched if len(offer_assignments) == 0: if not user_email: _prepare_redemption_data(coupon_data) else: for offer_assignment in offer_assignments: _prepare_redemption_data(coupon_data, offer_assignment) return redemptions_and_assignments @action(detail=False, url_path=r'(?P<enterprise_id>.+)/overview', permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_view_coupon', fn=lambda request, enterprise_id: enterprise_id) def overview(self, request, enterprise_id): # pylint: disable=unused-argument """ Overview of Enterprise coupons. Returns the following data: - Coupon ID - Coupon name. - Max number of codes available (Maximum coupon usage). - Number of codes. - Redemption count. - Valid from. - Valid end. """ enterprise_coupons = self.get_queryset() coupon_id = self.request.query_params.get('coupon_id', None) if coupon_id is not None: coupon = get_object_or_404(enterprise_coupons, id=coupon_id) serializer = self.get_serializer(coupon) return Response(serializer.data, status=status.HTTP_200_OK) page = self.paginate_queryset(enterprise_coupons) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) def _validate_coupon_availablity(self, coupon, message): """ Raise ValidationError with specified message if coupon is not available. """ if not is_coupon_available(coupon): raise DRFValidationError({'error': message}) def _validate_email_fields(self, subject, greeting, closing): """ Raise ValidationError if subject, greeting and/or closing is above the allowed limit. """ errors = {} max_field_limit = OFFER_ASSIGNMENT_EMAIL_TEMPLATE_FIELD_LIMIT if len(subject) > OFFER_ASSIGNMENT_EMAIL_SUBJECT_LIMIT: errors[ 'email_subject'] = 'Email subject must be {} characters or less'.format( OFFER_ASSIGNMENT_EMAIL_SUBJECT_LIMIT) if len(greeting) > max_field_limit: errors[ 'email_greeting'] = 'Email greeting must be {} characters or less'.format( max_field_limit) if len(closing) > max_field_limit: errors[ 'email_closing'] = 'Email closing must be {} characters or less'.format( max_field_limit) if errors: raise DRFValidationError({'error': errors}) def _create_offer_assignment_email_sent_record(self, enterprise_customer, email_type, template=None): OfferAssignmentEmailSentRecord.create_email_record( enterprise_customer, email_type, template) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def assign(self, request, pk): # pylint: disable=unused-argument """ Assign users by email to codes within the Coupon. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code assignment') subject = request.data.pop('template_subject', '') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') template_id = request.data.pop('template_id', None) template = OfferAssignmentEmailTemplates.get_template(template_id) enterprise_customer = coupon.attr.enterprise_customer_uuid self._validate_email_fields(subject, greeting, closing) serializer = CouponCodeAssignmentSerializer(data=request.data, context={ 'coupon': coupon, 'subject': subject, 'greeting': greeting, 'closing': closing, }) if serializer.is_valid(): serializer.save() # Create a record of the email sent self._create_offer_assignment_email_sent_record( enterprise_customer, ASSIGN, template) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def visibility(self, request, pk): # pylint: disable=unused-argument """ Assign users by email to codes within the Coupon. """ coupon = self.get_object() codes = request.data.get('code_ids') is_public = request.data.get('is_public') if codes and is_public is not None: coupon.attr.coupon_vouchers.vouchers.filter(code__in=codes).update( is_public=is_public) return Response(status=status.HTTP_200_OK) return Response( 'Must specify both a list of code_ids and an is_public boolean', status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=['post'], permission_classes=[IsAdminUser]) def create_refunded_voucher(self, request): # pylint: disable=unused-argument """ Creates new voucher in existing coupon for requested order number if possible. example Request: POST "http://localhost:18130/api/v2/enterprise/coupons/create_refunded_voucher/" { "order": "EDX-100038" } Example Responses >> { "order": "#EDX-100033", "code": "U6GN2OMLCLOCBL7V" } >> { "non_field_errors": [ "Your order #EDX-100005 can not be refunded as 'Multi-use' coupon are not supported to refund." ] } >> { "order": [ "Invalid order number or order #EDX-100005 does not exists." ] } """ serializer = RefundedOrderCreateVoucherSerializer( data=request.data, context={'request': request}) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def revoke(self, request, pk): # pylint: disable=unused-argument """ Revoke users by email from codes within the Coupon. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code revoke') subject = request.data.pop('template_subject', '') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') template_id = request.data.pop('template_id', None) template = OfferAssignmentEmailTemplates.get_template(template_id) enterprise_customer = coupon.attr.enterprise_customer_uuid self._validate_email_fields(subject, greeting, closing) assignments = request.data.get('assignments') serializer = CouponCodeRevokeSerializer(data=assignments, many=True, context={ 'coupon': coupon, 'subject': subject, 'greeting': greeting, 'closing': closing, }) if serializer.is_valid(): serializer.save() # Create a record of the email sent self._create_offer_assignment_email_sent_record( enterprise_customer, REVOKE, template) # unsubscribe user from receiving nudge emails CodeAssignmentNudgeEmails.unsubscribe_from_nudging( map(lambda assignment: assignment['code'], assignments), map(lambda assignment: assignment['email'], assignments)) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) @permission_required('enterprise.can_assign_coupon', fn=lambda request, pk: get_enterprise_from_product(pk) ) def remind(self, request, pk): # pylint: disable=unused-argument """ Remind users of pending offer assignments by email. """ coupon = self.get_object() self._validate_coupon_availablity( coupon, 'Coupon is not available for code remind') subject = request.data.pop('template_subject', '') greeting = request.data.pop('template_greeting', '') closing = request.data.pop('template_closing', '') template_id = request.data.pop('template_id', None) template = OfferAssignmentEmailTemplates.get_template(template_id) enterprise_customer = coupon.attr.enterprise_customer_uuid self._validate_email_fields(subject, greeting, closing) if request.data.get('assignments'): assignments = request.data.get('assignments') else: # If no assignment is passed, send reminder to all assignments associated with the coupon. vouchers = coupon.attr.coupon_vouchers.vouchers.all() code_filter = request.data.get('code_filter') if not code_filter: raise serializers.ValidationError( 'code_filter must be specified') if code_filter == VOUCHER_NOT_REDEEMED: assignment_usages = self._get_not_redeemed_usages(vouchers) elif code_filter == VOUCHER_PARTIAL_REDEEMED: assignment_usages = self._get_partial_redeemed_usages(vouchers) else: raise serializers.ValidationError( 'Invalid code_filter specified: {}'.format(code_filter)) assignments = [{ 'code': assignment['code'], 'email': assignment['user_email'] } for assignment in assignment_usages] serializer = CouponCodeRemindSerializer(data=assignments, many=True, context={ 'coupon': coupon, 'subject': subject, 'greeting': greeting, 'closing': closing, }) if serializer.is_valid(): serializer.save() # Create a record of the email sent self._create_offer_assignment_email_sent_record( enterprise_customer, REMIND, template) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)