import logging import zoneinfo import random import string from datetime import datetime, timedelta from dataclasses import dataclass from django.db import transaction, models, IntegrityError from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.core.exceptions import PermissionDenied from labs.models import ( User, UserCreditSummary, UserCreditDetail, CreditTransaction, CreditVoucher, EventVoucher, ExamAttempt, ExamVoucher, UserLabWorkshop, UserExamRedemption, CREDIT_VOUCHER_PATTERN, EXAM_VOUCHER_PATTERN, LarkMessage, ) from labs.billing.user import init_user_credit_record from labs.billing.utils import round_to_end_of_day, round_to_start_of_day from labs.billing.exceptions import InvalidVoucherCodeError, BillingException from labs.user_events import UserEvent, log_user_event from labs.site_settings import SiteSettings logger = logging.getLogger(__name__) # Free credits from a voucher expire 1 year after the voucher is redeemed, # independent of CreditVoucher.valid_before (which only gates redeemability). VOUCHER_FREE_CREDITS_EXPIRES_IN_DAYS = 365 @dataclass class RedeemSuccessResponse: message: str def redeem_voucher(user: User, voucher_code: str) -> RedeemSuccessResponse: voucher_code = voucher_code.strip() voucher_code = voucher_code.upper() if CREDIT_VOUCHER_PATTERN.match(voucher_code): return redeem_voucher_for_credits(user, voucher_code) if EXAM_VOUCHER_PATTERN.match(voucher_code): return redeem_voucher_for_exam(user, voucher_code) return redeem_voucher_for_workshop(user, voucher_code) @transaction.atomic def redeem_voucher_for_exam(user: User, voucher_code: str) -> RedeemSuccessResponse: """ Redeem an exam voucher code to grant user access to start a paid exam. An exam voucher is single-use. Redemption creates an unused UserExamRedemption record (exam_attempt IS NULL) which will be consumed when starting an attempt. """ code = voucher_code.strip().upper() if not EXAM_VOUCHER_PATTERN.match(code): raise InvalidVoucherCodeError( _("Invalid voucher code: %(code)s, pattern not match") % {"code": code} ) # Lock the user row to prevent concurrent redemptions wasting vouchers. User.objects.select_for_update().only("id").get(id=user.id) try: voucher = ( ExamVoucher.objects.select_for_update() .select_related("exam") .get(unique_code=code) ) except ExamVoucher.DoesNotExist: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": code} ) # Align with credits voucher: used single-use voucher returns "does not exist". if voucher.used != ExamVoucher.UsedChoices.N: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": code} ) if voucher.valid_before: valid_before = timezone.make_aware( datetime.combine(voucher.valid_before, datetime.min.time()), timezone=zoneinfo.ZoneInfo("UTC"), ) valid_before = round_to_end_of_day(valid_before) if valid_before < timezone.now(): raise InvalidVoucherCodeError( _("Voucher code %(code)s has expired.") % {"code": code} ) exam = voucher.exam if not exam.published: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": code} ) if exam.employee_only and not user.is_pingcap_employee: raise InvalidVoucherCodeError(_("This exam is for PingCAP employees only.")) if ExamAttempt.objects.filter( user_id=user.id, exam_id=exam.id, is_passed=True, ).exists(): raise InvalidVoucherCodeError(_("You have already passed this exam.")) if user.is_pingcap_employee or exam.credits == 0: raise InvalidVoucherCodeError(_("You can start this exam now without redeeming.")) if UserExamRedemption.objects.filter( user_id=user.id, exam_id=exam.id, exam_attempt__isnull=True, ).exists(): raise InvalidVoucherCodeError(_("You have already redeemed this exam.")) UserExamRedemption.objects.create( user=user, exam=exam, exam_attempt=None, redeem_type=UserExamRedemption.RedeemType.VOUCHER, redeem_detail={ "voucher_code": code, "marketing_code": voucher.marketing_code, }, ) voucher.used = ExamVoucher.UsedChoices.Y voucher.redeem_at = timezone.now() voucher.redeem_by = user voucher.save(update_fields=["used", "redeem_at", "redeem_by", "updated_at"]) logger.info(f"User {user.id} redeemed exam voucher {code} for exam {exam.id}.") log_user_event( user=user, event_name=UserEvent.EventName.REDEEM_EXAM_VOUCHER, event_body={ "voucher_code": code, "exam_id": exam.id, "exam_code": exam.code, "marketing_code": voucher.marketing_code, }, ) return RedeemSuccessResponse( message=_("Redeem success! You can now start the exam %(exam_name)s.") % {"exam_name": exam.code} ) @transaction.atomic def redeem_voucher_for_credits(user: User, voucher_code: str) -> RedeemSuccessResponse: """ Redeem a voucher code to convert it into user credits. Args: user: The user who is redeeming the voucher voucher_code: The voucher code to be redeemed Returns: RedeemSuccessResponse: The response of the redemption """ if not CREDIT_VOUCHER_PATTERN.match(voucher_code): raise InvalidVoucherCodeError( _("Invalid voucher code: %(code)s, pattern not match") % {"code": voucher_code} ) try: voucher = CreditVoucher.objects.select_for_update().get( unique_code=voucher_code, ) except CreditVoucher.DoesNotExist: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": voucher_code} ) if voucher.used != CreditVoucher.UsedChoices.N: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": voucher_code} ) valid_before = timezone.make_aware( datetime.combine(voucher.valid_before, datetime.min.time()), timezone=zoneinfo.ZoneInfo("UTC"), ) valid_before = round_to_end_of_day(valid_before) if valid_before < timezone.now(): raise InvalidVoucherCodeError( _("Voucher code %(code)s has expired.") % {"code": voucher_code} ) try: credit_summary = UserCreditSummary.objects.select_for_update().get(user=user) except UserCreditSummary.DoesNotExist: init_user_credit_record(user) credit_summary = UserCreditSummary.objects.select_for_update().get(user=user) previous = { "free_credits": credit_summary.free_credits, "paid_credits": credit_summary.paid_credits, } voucher.used = CreditVoucher.UsedChoices.Y voucher.redeem_at = timezone.now() voucher.redeem_by = user voucher.save() now = timezone.now() credits_expires_at = round_to_end_of_day( now + timedelta(days=VOUCHER_FREE_CREDITS_EXPIRES_IN_DAYS) ) free_credit_detail = UserCreditDetail.objects.create( user=user, credit_type=UserCreditDetail.CreditType.FREE, expires_at=credits_expires_at, amount=voucher.credits, initial_amount=voucher.credits, remark=f"Redeem voucher {voucher_code}", ) CreditTransaction.objects.create( user=user, transaction_type=CreditTransaction.TransactionType.BONUS, paid_credits=0, free_credits=voucher.credits, remark=f"Redeem voucher {voucher_code}", log={ "free_credit": [ { "id": free_credit_detail.id, "amount": free_credit_detail.amount, } ], "previous": previous, }, ) free_credits_amount = ( UserCreditDetail.objects.filter( user=user, credit_type=UserCreditDetail.CreditType.FREE, amount__gt=0, expires_at__gte=now, ).aggregate(total_amount=models.Sum("amount"))["total_amount"] or 0 ) credit_summary.free_credits = free_credits_amount credit_summary.save() logger.info( f"User {user.id} redeemed voucher {voucher_code} for {voucher.credits} credits." ) log_user_event( user=user, event_name=UserEvent.EventName.REDEEM_CREDITS_VOUCHER, event_body={"voucher_code": voucher_code}, ) return RedeemSuccessResponse( message=_( "Successfully redeemed! %(credits)s free credits have been added to your account. " "They will expire on %(expires_at)s UTC." ) % { "credits": voucher.credits, "expires_at": credits_expires_at.strftime("%Y-%m-%d %H:%M:%S"), } ) def redeem_voucher_for_workshop(user: User, voucher_code: str) -> RedeemSuccessResponse: """ Redeem a voucher code to convert it into workshop. Args: user: The user who is redeeming the voucher voucher_code: The voucher code to be redeemed Returns: RedeemSuccessResponse: The response of the redemption Raises: InvalidVoucherCodeError: If the voucher code is invalid """ try: voucher = EventVoucher.objects.get( unique_code=voucher_code, ) except EventVoucher.DoesNotExist: raise InvalidVoucherCodeError( _("Voucher code %(code)s does not exist.") % {"code": voucher_code} ) if UserLabWorkshop.objects.filter( user=user, workshop_name=voucher.unique_code, ).exists(): raise InvalidVoucherCodeError(_("You have already redeemed this voucher.")) valid_from = timezone.make_aware( datetime.combine(voucher.start_begin_date, datetime.min.time()), timezone=zoneinfo.ZoneInfo("UTC"), ) valid_from = round_to_start_of_day(valid_from) valid_until = timezone.make_aware( datetime.combine(voucher.start_end_date, datetime.min.time()), timezone=zoneinfo.ZoneInfo("UTC"), ) valid_until = round_to_end_of_day(valid_until) now = timezone.now() if valid_until < now: raise InvalidVoucherCodeError( _("Workshop %(name)s has ended.") % {"name": voucher.event_name} ) UserLabWorkshop.objects.create( user=user, workshop=voucher, workshop_name=voucher.unique_code, lab_codes=voucher.lab_codes, lab_group_name="-", # TODO: remove this after all vouchers are updated valid_from=valid_from, valid_until=valid_until, ) logger.info( f"User {user.id} redeemed voucher {voucher_code} for {len(voucher.lab_codes)} labs." ) log_user_event( user=user, event_name=UserEvent.EventName.REDEEM_EVENT_VOUCHER, event_body={"voucher_code": voucher_code}, ) return RedeemSuccessResponse( message=_("Redeem success! Follow the instructor's guide to get started.") ) def _generate_credit_voucher_code(): random_chars = "".join(random.choices(string.ascii_uppercase + string.digits, k=25)) formatted_string = "-".join(random_chars[i : i + 5] for i in range(0, 25, 5)) return formatted_string def bulk_generate_credits_voucher( *, user: User, lark_user_union_id: str | None, redeemable_days: int, campaign_code: str, credits_per_voucher: int, voucher_count: int, voucher_category: str, customer_name: str, ) -> list[str]: """ Bulk generate credits vouchers. Args: user: The user who is generating the vouchers lark_user_union_id: The lark user union id of the user, used to send notification to the user redeemable_days: The number of days the vouchers are redeemable campaign_code: The campaign code of the vouchers credits_per_voucher: The number of credits per voucher voucher_count: The number of vouchers to generate voucher_category: The category of the vouchers customer_name: The name of the customer Returns: A list of voucher codes """ from labs.tasks.credit_voucher import send_message_to_lark_user if not user.is_staff: raise PermissionDenied("User must be staff to generate credits vouchers.") if not (1 <= credits_per_voucher <= 600): raise ValueError("credits_per_voucher must be between 1 and 600") if redeemable_days < 1: raise ValueError("redeemable_days must be greater than 0") if not (1 <= voucher_count <= 1000): raise ValueError("voucher_count must be between 1 and 1000") valid_before = (timezone.now() + timedelta(days=redeemable_days)).date() max_attempts = 5 last_error: Exception | None = None for attempt in range(max_attempts): voucher_codes = [_generate_credit_voucher_code() for _ in range(voucher_count)] credit_voucher_objs = [ CreditVoucher( unique_code=code, credits=credits_per_voucher, valid_before=valid_before, used=CreditVoucher.UsedChoices.N, marketing_code=campaign_code, category=voucher_category, customer_organization=customer_name, ) for code in voucher_codes ] try: with transaction.atomic(): CreditVoucher.objects.bulk_create(credit_voucher_objs) if lark_user_union_id: send_message_to_lark_user.apply_async( args=( lark_user_union_id, voucher_codes, valid_before, credits_per_voucher, voucher_category, customer_name, campaign_code, voucher_count, ), countdown=5, ) LarkMessage.send( webhook=SiteSettings.credit_voucher_staff_lark_webhook, message_type=LarkMessage.MessageType.MARKDOWN, content=f"""campaign_code: {campaign_code} redeemable_days: {redeemable_days} credits_per_voucher: {credits_per_voucher} voucher_count: {voucher_count} voucher_category: {voucher_category} customer_name: {customer_name} """, title=f"New Credits Vouchers Generated by Approval!", comment=f"", ) return voucher_codes except IntegrityError as e: logger.warning( "IntegrityError when bulk creating vouchers (attempt %s/%s), will retry.", attempt + 1, max_attempts, exc_info=True, ) last_error = e continue logger.error( "Failed to generate unique voucher codes after %s attempts.", max_attempts, exc_info=last_error, ) raise BillingException("Failed to generate unique voucher codes, please retry.")