diff --git a/sheets/exceptions.py b/sheets/exceptions.py index c3b35ef76..40f7a6f9e 100644 --- a/sheets/exceptions.py +++ b/sheets/exceptions.py @@ -40,3 +40,7 @@ class FailedBatchRequestException(Exception): # noqa: N818 """ General exception for a failure during a Google batch API request """ + + +class CouponAssignmentError(Exception): + """Custom exception for coupon assignment errors.""" diff --git a/sheets/management/commands/process_coupon_assignment_sheet.py b/sheets/management/commands/process_coupon_assignment_sheet.py index 32d3dfdb2..aa1de4606 100644 --- a/sheets/management/commands/process_coupon_assignment_sheet.py +++ b/sheets/management/commands/process_coupon_assignment_sheet.py @@ -5,11 +5,7 @@ from django.core.management import BaseCommand, CommandError -from ecommerce.models import BulkCouponAssignment -from sheets.api import ExpandedSheetsClient, get_authorized_pygsheets_client -from sheets.coupon_assign_api import CouponAssignmentHandler -from sheets.management.utils import get_assignment_spreadsheet_by_title -from sheets.utils import google_date_string_to_datetime, spreadsheet_repr +from sheets.utils import fetch_and_process_coupon_assignment class Command(BaseCommand): @@ -45,57 +41,30 @@ def add_arguments(self, parser): super().add_arguments(parser) def handle(self, *args, **options): # noqa: ARG002 - if not options["id"] and not options["title"]: + sheet_id = options.get("id") + title = options.get("title") + + if not sheet_id and not title: raise CommandError("Need to provide --id or --title") # noqa: EM101 - pygsheets_client = get_authorized_pygsheets_client() - # Fetch the correct spreadsheet - if options["id"]: - spreadsheet = pygsheets_client.open_by_key(options["id"]) - else: - spreadsheet = get_assignment_spreadsheet_by_title( - pygsheets_client, options["title"] - ) - # Process the sheet - self.stdout.write( - "Found spreadsheet ({}). Processing...".format( # noqa: UP032 - spreadsheet_repr(spreadsheet) - ) - ) + use_sheet_id = bool(sheet_id) + value = sheet_id if use_sheet_id else title - expanded_sheets_client = ExpandedSheetsClient(pygsheets_client) - metadata = expanded_sheets_client.get_drive_file_metadata( - file_id=spreadsheet.id, fields="modifiedTime" - ) - sheet_last_modified = google_date_string_to_datetime(metadata["modifiedTime"]) - bulk_assignment, created = BulkCouponAssignment.objects.get_or_create( - assignment_sheet_id=spreadsheet.id - ) - - if ( - bulk_assignment.sheet_last_modified_date - and sheet_last_modified <= bulk_assignment.sheet_last_modified_date - and not options["force"] - ): - raise CommandError( - "Spreadsheet is unchanged since it was last processed (%s, last modified: %s). " # noqa: UP031 - "Add the '-f/--force' flag to process it anyway." - % (spreadsheet_repr(spreadsheet), sheet_last_modified.isoformat()) + try: + spreadsheet, num_created, num_removed, bulk_assignment_id = fetch_and_process_coupon_assignment( + use_sheet_id=use_sheet_id, value=value, force=options.get("force") ) - coupon_assignment_handler = CouponAssignmentHandler( - spreadsheet_id=spreadsheet.id, bulk_assignment=bulk_assignment - ) - ( - bulk_assignment, - num_created, - num_removed, - ) = coupon_assignment_handler.process_assignment_spreadsheet() - bulk_assignment.sheet_last_modified_date = sheet_last_modified - bulk_assignment.save() - self.stdout.write( - self.style.SUCCESS( - f"Successfully processed coupon assignment sheet ({spreadsheet_repr(spreadsheet)}).\n" - f"{num_created} individual coupon assignment(s) added, {num_removed} deleted (BulkCouponAssignment id: {bulk_assignment.id})." + self.stdout.write( + self.style.SUCCESS( + f"Successfully processed coupon assignment sheet ({value}).\n" + f"{num_created} individual coupon assignment(s) added, {num_removed} deleted " + f"(BulkCouponAssignment id: {bulk_assignment_id})." + ) ) - ) + + except CommandError as e: + raise CommandError(str(e)) + + except Exception as e: + raise CommandError(f"An unexpected error occurred: {e}") diff --git a/sheets/urls.py b/sheets/urls.py index 6ab0f89eb..382905243 100644 --- a/sheets/urls.py +++ b/sheets/urls.py @@ -19,4 +19,9 @@ views.handle_watched_sheet_update, name="handle-watched-sheet-update", ), + re_path( + r"^api/sheets/process_coupon_sheet_assignment/", + views.ProcessCouponSheetAssignmentView.as_view(), + name="process-coupon-sheet-assignment", +) ] diff --git a/sheets/utils.py b/sheets/utils.py index ecf365377..261bb5bb2 100644 --- a/sheets/utils.py +++ b/sheets/utils.py @@ -9,7 +9,9 @@ from django.conf import settings from django.urls import reverse +from ecommerce.models import BulkCouponAssignment from mitxpro.utils import matching_item_index +# from sheets.api import ExpandedSheetsClient, get_authorized_pygsheets_client from sheets.constants import ( ASSIGNMENT_SHEET_PREFIX, GOOGLE_AUTH_PROVIDER_X509_CERT_URL, @@ -25,6 +27,7 @@ WORKSHEET_TYPE_REFUND, ) +from sheets.exceptions import CouponAssignmentError def generate_google_client_config(): """Helper method to generate Google client config based on app settings""" @@ -630,3 +633,61 @@ def build_drive_file_email_share_request(file_id, email_to_share): supportsTeamDrives=True, **added_kwargs, ) + + +def assign_coupons_from_spreadsheet(use_sheet_id: bool, value: str, force: bool = False): + """ + Fetches and processes a coupon assignment spreadsheet using either the sheet ID or title. + + Args: + use_sheet_id (bool): If True, 'value' represents the spreadsheet ID; otherwise, it represents the title. + value (str): The spreadsheet ID or title. + + Returns: + dict: A dictionary containing the result of the processing. + """ + + if not value: + raise CouponAssignmentError("Spreadsheet identifier (ID or Title) is required.") + + from sheets.api import ExpandedSheetsClient, get_authorized_pygsheets_client + from sheets.management.utils import get_assignment_spreadsheet_by_title + + pygsheets_client = get_authorized_pygsheets_client() + + # Fetch the correct spreadsheet + if use_sheet_id: + spreadsheet = pygsheets_client.open_by_key(value) + else: + spreadsheet = get_assignment_spreadsheet_by_title(pygsheets_client, value) + + expanded_sheets_client = ExpandedSheetsClient(pygsheets_client) + metadata = expanded_sheets_client.get_drive_file_metadata( + file_id=spreadsheet.id, fields="modifiedTime" + ) + sheet_last_modified = google_date_string_to_datetime(metadata["modifiedTime"]) + + bulk_assignment, created = BulkCouponAssignment.objects.get_or_create( + assignment_sheet_id=spreadsheet.id + ) + + if ( + bulk_assignment.sheet_last_modified_date + and sheet_last_modified <= bulk_assignment.sheet_last_modified_date + and not force + ): + raise CouponAssignmentError( + f"Spreadsheet is unchanged since last processed ({spreadsheet_repr(spreadsheet)}, last modified: {sheet_last_modified.isoformat()})." + ) + + from sheets.coupon_assign_api import CouponAssignmentHandler + coupon_assignment_handler = CouponAssignmentHandler( + spreadsheet_id=spreadsheet.id, bulk_assignment=bulk_assignment + ) + + bulk_assignment, num_created, num_removed = coupon_assignment_handler.process_assignment_spreadsheet() + bulk_assignment.sheet_last_modified_date = sheet_last_modified + bulk_assignment.save() + + return spreadsheet_repr(spreadsheet), num_created, num_removed, bulk_assignment.id + diff --git a/sheets/views.py b/sheets/views.py index f6807cda4..dcbc4ee12 100644 --- a/sheets/views.py +++ b/sheets/views.py @@ -13,6 +13,8 @@ from google.auth.exceptions import GoogleAuthError from google_auth_oauthlib.flow import Flow from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView from mitxpro.utils import now_in_utc from sheets import tasks @@ -24,7 +26,8 @@ SHEET_TYPE_ENROLL_CHANGE, ) from sheets.models import GoogleApiAuth, GoogleFileWatch -from sheets.utils import generate_google_client_config +from sheets.utils import generate_google_client_config, assign_coupons_from_spreadsheet +from sheets.exceptions import CouponAssignmentError log = logging.getLogger(__name__) @@ -151,3 +154,44 @@ def handle_watched_sheet_update(request): tasks.handle_unprocessed_deferral_requests.delay() return HttpResponse(status=status.HTTP_200_OK) + + +class ProcessCouponSheetAssignmentView(APIView): + def post(self, request): + """Handles the assignment of coupons from a sheet (by ID or Title).""" + sheet_identifier_type = request.data.get("sheet_identifier_type") + sheet_identifier_value = request.data.get("sheet_identifier_value") + force = request.data.get("force", True) + + if sheet_identifier_type is None or not sheet_identifier_value: + return Response( + {"error": "Both 'sheet_identifier_type' and 'sheet_value' are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # try: + # Call the updated utility function for coupon assignment + spreadsheet_repr, num_created, num_removed, bulk_assignment_id = assign_coupons_from_spreadsheet( + sheet_identifier_type=="id", sheet_identifier_value, force + ) + + # Return success response with relevant data + return Response( + { + "message": f"Successfully processed coupon assignment sheet ({spreadsheet_repr}).", + "num_created": num_created, + "num_removed": num_removed, + "bulk_assignment_id": bulk_assignment_id, + }, + status=status.HTTP_200_OK, + ) + + # except CouponAssignmentError as e: + # # Handle known errors (like missing or incorrect sheet) + # return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + # except Exception as e: + # # Catch any unexpected errors + # return Response( + # {"error": "An error occurred while processing the coupon sheet."}, + # status=status.HTTP_500_INTERNAL_SERVER_ERROR, + # ) \ No newline at end of file diff --git a/static/js/components/forms/CouponSheetProcessForm.js b/static/js/components/forms/CouponSheetProcessForm.js new file mode 100644 index 000000000..3e874f5c6 --- /dev/null +++ b/static/js/components/forms/CouponSheetProcessForm.js @@ -0,0 +1,77 @@ +import React from "react"; +import { Formik, Field, Form, ErrorMessage } from "formik"; +import * as yup from "yup"; + +import FormError from "./elements/FormError"; + +import { SHEET_IDENTIFIER_ID, SHEET_IDENTIFIER_TITLE } from "../../constants"; + +type CouponSheetProcessFormProps = { + onSubmit: Function, +}; + +const couponValidations = yup.object().shape({ + sheet_identifier_value: yup + .string() + .required("Sheet ID or Title is required") + .matches( + /^[\w\n ]+$/, + "Only letters, numbers, spaces, and underscores allowed" + ), +}); + +export const CouponSheetProcessForm = ({ onSubmit }: CouponSheetProcessFormProps) => { + return ( + ( +
+
+
+ + + +
+ +
+ + +
+
+ +
+ +
+
+ )} + /> + ); +}; diff --git a/static/js/constants.js b/static/js/constants.js index 6218a5f7d..2e5496069 100644 --- a/static/js/constants.js +++ b/static/js/constants.js @@ -10,6 +10,9 @@ export const ENROLLABLE_ITEM_ID_SEPARATOR = "+"; export const PRODUCT_TYPE_PROGRAM = "program"; export const PRODUCT_TYPE_COURSERUN = "courserun"; +export const SHEET_IDENTIFIER_ID = "id"; +export const SHEET_IDENTIFIER_TITLE = "title"; + export const GENDER_CHOICES = [ ["m", "Male"], ["f", "Female"], diff --git a/static/js/containers/pages/admin/EcommerceAdminPages.js b/static/js/containers/pages/admin/EcommerceAdminPages.js index e93cf39dc..17a66a953 100644 --- a/static/js/containers/pages/admin/EcommerceAdminPages.js +++ b/static/js/containers/pages/admin/EcommerceAdminPages.js @@ -10,6 +10,7 @@ import { routes } from "../../../lib/urls"; import CouponCreationPage from "./CreateCouponPage"; import DeactivateCouponPage from "./DeactivateCouponPage"; +import ProcessCouponAssignmentSheetPage from "./ProcessCouponAssignmentSheetPage"; const EcommerceAdminIndexPage = () => (
@@ -27,6 +28,9 @@ const EcommerceAdminIndexPage = () => ( )} +
  • + Process Coupon Assignment Sheet +
  • ); @@ -49,6 +53,11 @@ const EcommerceAdminPages = () => ( path={routes.ecommerceAdmin.deactivate} component={DeactivateCouponPage} /> + diff --git a/static/js/containers/pages/admin/ProcessCouponAssignmentSheetPage.js b/static/js/containers/pages/admin/ProcessCouponAssignmentSheetPage.js new file mode 100644 index 000000000..624eea03d --- /dev/null +++ b/static/js/containers/pages/admin/ProcessCouponAssignmentSheetPage.js @@ -0,0 +1,131 @@ +// @flow +/* global SETTINGS: false */ +import React from "react"; +import DocumentTitle from "react-document-title"; +import { DEACTIVATE_COUPONS_PAGE_TITLE } from "../../../constants"; +import { mutateAsync } from "redux-query"; +import { compose } from "redux"; +import { connect } from "react-redux"; +import { Link } from "react-router-dom"; + +import { CouponSheetProcessForm } from "../../../components/forms/CouponSheetProcessForm"; +import queries from "../../../lib/queries"; +import { routes } from "../../../lib/urls"; + +import type { Response } from "redux-query"; +import { createStructuredSelector } from "reselect"; + +type State = { + submitting: ?boolean, + isProcessed: ?boolean, + formData: Object, + numOfCouponsDeactivated: number, +}; + +type DispatchProps = {| + assignSheetCoupons: (payload: Object) => Promise>, +|}; + +type Props = {| + ...DispatchProps, +|}; + +export class ProcessCouponAssignmentSheetPage extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + submitting: false, + isProcessed: false, + formData: {}, + skippedCodes: [], + numOfCouponsDeactivated: 0, + }; + } + + + onModalSubmit = async () => { + await this.setState({ ...this.state, submitting: true }); + const { deactivateCoupon } = this.props; + const { formData } = this.state; + const result = await deactivateCoupon(formData); + + await this.setState({ + submitting: false, + formData: formData, + isProcessed: true, + skippedCodes: result.body.skipped_codes || [], + numOfCouponsDeactivated: result.body.num_of_coupons_deactivated, + }); + }; + + onSubmit = async (formData: Object, { setSubmitting }: Object) => { + console.log("formData", formData); + // await this.setState({ ...this.state, formData: formData }); + const result = await this.props.assignSheetCoupons(formData); + setSubmitting(false); + }; + + clearSuccess = async () => { + await this.setState({ + submitting: false, + isProcessed: false, + formData: {}, + skippedCodes: [], + numOfCouponsDeactivated: 0, + }); + }; + + render() { + const { + isProcessed, + numOfCouponsDeactivated, + } = this.state; + return ( + +
    +

    + + Back to Ecommerce Admin + +

    +

    Process Coupon Assignment Sheets

    + {isProcessed ? ( +
    + {numOfCouponsDeactivated > 0 && ( +

    Sheet successfully processed.

    + )} + +
    + +
    +
    + ) : ( + + )} +
    +
    + ); + } +} + +const assignSheetCoupons = (payload: Object) => + mutateAsync(queries.ecommerce.sheetCouponsAssignment(payload)); + +const mapStateToProps = createStructuredSelector({}); + +const mapDispatchToProps = { + assignSheetCoupons: assignSheetCoupons, +}; + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), +)(ProcessCouponAssignmentSheetPage); diff --git a/static/js/lib/queries/ecommerce.js b/static/js/lib/queries/ecommerce.js index 6feb7ee2c..8a649b93a 100755 --- a/static/js/lib/queries/ecommerce.js +++ b/static/js/lib/queries/ecommerce.js @@ -117,6 +117,15 @@ export default { ...DEFAULT_POST_OPTIONS, }, }), + sheetCouponsAssignment: (payload: Object) => ({ + queryKey: "couponsAssignment", + url: "/api/sheets/process_coupon_sheet_assignment/", + body: payload, + options: { + method: "POST", + ...DEFAULT_POST_OPTIONS, + }, + }), b2bCheckoutMutation: (payload: B2BCheckoutPayload) => ({ queryKey: "b2bCheckoutMutation", url: "/api/b2b/checkout/", diff --git a/static/js/lib/urls.js b/static/js/lib/urls.js index 755cc73e2..a68015737 100644 --- a/static/js/lib/urls.js +++ b/static/js/lib/urls.js @@ -48,6 +48,7 @@ export const routes = { index: "", coupons: "coupons/", deactivate: "deactivate-coupons/", + processSheets: "process-sheets/", }), ecommerceBulk: include("/ecommerce/bulk/", {