diff --git a/hackathon_site/dashboard/frontend/src/api/helpers.ts b/hackathon_site/dashboard/frontend/src/api/helpers.ts index 664027c..a14db71 100644 --- a/hackathon_site/dashboard/frontend/src/api/helpers.ts +++ b/hackathon_site/dashboard/frontend/src/api/helpers.ts @@ -97,7 +97,10 @@ export const teamOrderListSerialization = ( ); if (hardwareInTableRow.length > 0 && !allItemsReturnedOrRejected) { - (order.status === "Submitted" || order.status === "Ready for Pickup" + // include "In Progress" status with pending orders so admins can see who's packing + (order.status === "Submitted" || + order.status === "In Progress" || + order.status === "Ready for Pickup" ? pendingOrders : checkedOutOrders ).push({ @@ -106,6 +109,8 @@ export const teamOrderListSerialization = ( hardwareInTableRow, createdTime: order.created_at, updatedTime: order.updated_at, + packing_admin_id: order.packing_admin_id, // track who is packing this order + packing_admin_name: order.packing_admin_name, // display admin name in ui }); } } @@ -205,10 +210,13 @@ export const sortCheckedOutOrders = ( export const sortPendingOrders = (orders: OrderInTable[]): OrderInTable[] => { let ready_orders = []; + let in_progress_orders = []; // track orders currently being packed let submitted_orders = []; for (let order of orders) { if (order.status === "Ready for Pickup") { ready_orders.push(order); + } else if (order.status === "In Progress") { + in_progress_orders.push(order); } else { submitted_orders.push(order); } @@ -220,6 +228,13 @@ export const sortPendingOrders = (orders: OrderInTable[]): OrderInTable[] => { ); }); + in_progress_orders.sort((order1, order2) => { + return ( + new Date(order1.updatedTime).valueOf() - + new Date(order2.updatedTime).valueOf() + ); + }); + submitted_orders.sort((order1, order2) => { return ( new Date(order1.updatedTime).valueOf() - @@ -227,6 +242,13 @@ export const sortPendingOrders = (orders: OrderInTable[]): OrderInTable[] => { ); }); - orders.splice(0, orders.length, ...submitted_orders, ...ready_orders); + // sort order: submitted first, then in progress (being packed), then ready for pickup + orders.splice( + 0, + orders.length, + ...submitted_orders, + ...in_progress_orders, + ...ready_orders + ); return orders; }; diff --git a/hackathon_site/dashboard/frontend/src/api/types.ts b/hackathon_site/dashboard/frontend/src/api/types.ts index 03e8be1..ed44e3a 100644 --- a/hackathon_site/dashboard/frontend/src/api/types.ts +++ b/hackathon_site/dashboard/frontend/src/api/types.ts @@ -103,12 +103,12 @@ export interface ProfileWithUser extends ProfileWithoutTeamNumber { /** Orders API */ export type OrderStatus = | "Submitted" + | "In Progress" // new status for tracking order packing | "Ready for Pickup" | "Picked Up" | "Cancelled" | "Returned" - | "Pending" - | "In Progress"; + | "Pending"; export type PartReturnedHealth = | "Healthy" @@ -132,6 +132,8 @@ export interface Order { total_credits: number; created_at: string; updated_at: string; + packing_admin_id?: number | null; // track which admin is packing this order + packing_admin_name?: string | null; // admin's full name for display } export type OrderOrdering = "" | "created_at" | "-created_at"; @@ -157,6 +159,8 @@ export interface OrderInTable { status: OrderStatus; createdTime: string; updatedTime: string; + packing_admin_id?: number | null; // track which admin is packing this order + packing_admin_name?: string | null; // admin's full name for display } export type ReturnedItem = ItemsInOrder & { quantity: number; time: string }; @@ -199,3 +203,11 @@ export interface Incident { created_at: string; updated_at: string; } + +/** Order Lock API */ +export interface OrderLockStatus { + orders_locked: boolean; + locked_by: string | null; + locked_at: string | null; + reason: string; +} diff --git a/hackathon_site/dashboard/frontend/src/components/cart/CartSummary/CartSummary.tsx b/hackathon_site/dashboard/frontend/src/components/cart/CartSummary/CartSummary.tsx index f4fa475..c7e11a7 100644 --- a/hackathon_site/dashboard/frontend/src/components/cart/CartSummary/CartSummary.tsx +++ b/hackathon_site/dashboard/frontend/src/components/cart/CartSummary/CartSummary.tsx @@ -16,6 +16,13 @@ import { teamSelector, teamSizeSelector } from "slices/event/teamSlice"; import { isTestUserSelector } from "slices/users/userSlice"; import { projectDescriptionSelector } from "slices/event/teamSlice"; import { getCreditsUsedSelector } from "slices/order/orderSlice"; +import { + fetchLockStatus, + isLoadingSelector as isLockLoadingSelector, + ordersLockedSelector, +} from "slices/hardware/orderLockSlice"; +import { AppDispatch } from "slices/store"; +import { displaySnackbar } from "slices/ui/uiSlice"; import { hardwareSignOutEndDate, hardwareSignOutStartDate, @@ -33,13 +40,28 @@ const CartSummary = () => { const subtotalCredits = useSelector(subtotalCreditsSelector); const creditsUsed = useSelector(getCreditsUsedSelector); const creditsAvailable = useSelector(teamSelector)?.credits; + const ordersLocked = useSelector(ordersLockedSelector); + const isLockLoading = useSelector(isLockLoadingSelector); const projectedCredits = creditsAvailable ? creditsAvailable - creditsUsed - subtotalCredits : 0; const teamSizeValid = teamSize >= minTeamSize && teamSize <= maxTeamSize; - const dispatch = useDispatch(); - const onSubmit = () => { + const dispatch = useDispatch(); + const onSubmit = async () => { if (cartQuantity > 0) { + // Check lock status before submitting + const lockStatus = await dispatch(fetchLockStatus()).unwrap(); + if (lockStatus.orders_locked) { + dispatch( + displaySnackbar({ + message: "Orders are currently locked! Refreshing page...", + options: { + variant: "error", + }, + }) + ); + return; + } dispatch(submitOrder()); } }; @@ -77,13 +99,16 @@ const CartSummary = () => { variant="contained" className={styles.btn} disabled={ + cartQuantity === 0 || cartQuantity === 0 || cartOrderLoading || + isLockLoading || !teamSizeValid || !projectDescription || // Checks if projectDescription is null, undefined, or an empty string (projectDescription && projectDescription.length < minProjectDescriptionLength) || (!isTestUser && isOutsideSignOutPeriod) || + (!isTestUser && ordersLocked) || projectedCredits < 0 } onClick={onSubmit} diff --git a/hackathon_site/dashboard/frontend/src/components/general/AlertBox/AlertBox.tsx b/hackathon_site/dashboard/frontend/src/components/general/AlertBox/AlertBox.tsx index 20a1e34..db9b625 100644 --- a/hackathon_site/dashboard/frontend/src/components/general/AlertBox/AlertBox.tsx +++ b/hackathon_site/dashboard/frontend/src/components/general/AlertBox/AlertBox.tsx @@ -2,32 +2,45 @@ import React, { ReactElement } from "react"; import { Alert, AlertTitle } from "@material-ui/lab"; interface ErrorBoxProps { - error?: string[] | string; + error?: string[] | string | object; body?: ReactElement; type?: "error" | "info" | "success" | "warning"; title?: string; } const AlertBox = ({ error, body, type, title, ...otherProps }: ErrorBoxProps) => { + // Helper function to convert error to displayable string + const getErrorMessage = (err: unknown): string => { + if (typeof err === "string") return err; + if (typeof err === "object" && err !== null) { + // Handle common error object shapes + if ("message" in err) return String((err as { message: unknown }).message); + if ("detail" in err) return String((err as { detail: unknown }).detail); + if ("error" in err) return String((err as { error: unknown }).error); + return JSON.stringify(err); + } + return String(err); + }; + return ( - {typeof error === "object" ? ( + {Array.isArray(error) ? ( <> {title ?? "An error has occurred because:"} ) : ( <> {title ?? "An error has occurred"} - {body || error} + {body || getErrorMessage(error)} )} diff --git a/hackathon_site/dashboard/frontend/src/components/general/OrderLockAlert/OrderLockAlert.tsx b/hackathon_site/dashboard/frontend/src/components/general/OrderLockAlert/OrderLockAlert.tsx new file mode 100644 index 0000000..11810e7 --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/general/OrderLockAlert/OrderLockAlert.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import AlertBox from "components/general/AlertBox/AlertBox"; +import { ordersLockedSelector } from "slices/hardware/orderLockSlice"; + +const OrderLockAlert = () => { + const ordersLocked = useSelector(ordersLockedSelector); + + return ordersLocked ? ( + + ) : null; +}; + +export default OrderLockAlert; diff --git a/hackathon_site/dashboard/frontend/src/components/orders/OrderCard/OrderCard.tsx b/hackathon_site/dashboard/frontend/src/components/orders/OrderCard/OrderCard.tsx index 32d86b9..9859db0 100644 --- a/hackathon_site/dashboard/frontend/src/components/orders/OrderCard/OrderCard.tsx +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrderCard/OrderCard.tsx @@ -8,9 +8,17 @@ interface OrderProps { time: string; id: number; status: string; + packingAdminName?: string | null; // display who is packing this order } -const OrderCard = ({ teamCode, orderQuantity, time, id, status }: OrderProps) => { +const OrderCard = ({ + teamCode, + orderQuantity, + time, + id, + status, + packingAdminName, +}: OrderProps) => { const date = new Date(time); const month = date.toLocaleString("default", { month: "short" }); const day = date.getDate(); @@ -25,6 +33,10 @@ const OrderCard = ({ teamCode, orderQuantity, time, id, status }: OrderProps) => { title: "Order Qty", value: orderQuantity }, { title: "Time", value: `${month} ${day}, ${hoursAndMinutes}` }, { title: "ID", value: id }, + // show which admin is packing this order when status is "In Progress" + ...(status === "In Progress" && packingAdminName + ? [{ title: "Packing Admin", value: packingAdminName }] + : []), ]; return ( diff --git a/hackathon_site/dashboard/frontend/src/components/orders/OrderLockButton/OrderLockButton.tsx b/hackathon_site/dashboard/frontend/src/components/orders/OrderLockButton/OrderLockButton.tsx new file mode 100644 index 0000000..3d0e5dc --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrderLockButton/OrderLockButton.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Button from "@material-ui/core/Button"; +import LockIcon from "@material-ui/icons/Lock"; +import LockOpenIcon from "@material-ui/icons/LockOpen"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { + lockStatusSelector, + isLoadingSelector, + toggleLock, +} from "slices/hardware/orderLockSlice"; +import { displaySnackbar } from "slices/ui/uiSlice"; +import { AppDispatch } from "slices/store"; + +const OrderLockButton = () => { + const dispatch = useDispatch(); + const lockStatus = useSelector(lockStatusSelector); + const isLoading = useSelector(isLoadingSelector); + + const handleToggle = async () => { + const newLockState = !lockStatus.orders_locked; + try { + const result = await dispatch( + toggleLock({ + orders_locked: newLockState, + reason: "", + }) + ); + + if (toggleLock.fulfilled.match(result)) { + dispatch( + displaySnackbar({ + message: newLockState + ? "Order submissions have been locked" + : "Order submissions have been unlocked", + options: { variant: "success" }, + }) + ); + } else { + dispatch( + displaySnackbar({ + message: "Failed to toggle lock status", + options: { variant: "error" }, + }) + ); + } + } catch (error) { + dispatch( + displaySnackbar({ + message: "Failed to toggle lock status", + options: { variant: "error" }, + }) + ); + } + }; + + return ( + + ); +}; + +export default OrderLockButton; diff --git a/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx b/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx index b7dccf6..67ae99f 100644 --- a/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx @@ -90,6 +90,11 @@ const OrderFilter = ({ handleReset, handleSubmit }: FormikValues) => { status: "Submitted", numOrders: numStatuses["Submitted"], }, + { + // new status to show orders currently being packed + status: "In Progress", + numOrders: numStatuses["In Progress"], + }, { status: "Ready for Pickup", numOrders: numStatuses["Ready for Pickup"], diff --git a/hackathon_site/dashboard/frontend/src/components/orders/OrdersTable/OrdersTable.tsx b/hackathon_site/dashboard/frontend/src/components/orders/OrdersTable/OrdersTable.tsx index af746c7..0b0ecbd 100644 --- a/hackathon_site/dashboard/frontend/src/components/orders/OrdersTable/OrdersTable.tsx +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrdersTable/OrdersTable.tsx @@ -99,6 +99,14 @@ const OrdersTable = ({ ordersData }: OrdersTableProps) => { minWidth: 250, renderCell: (params) => , }, + { + // show which admin is currently packing this order to prevent double-packing + field: "packing_admin_name", + headerName: "Packing Admin", + flex: 1, + minWidth: 150, + valueGetter: (params) => params.value || "-", + }, { field: "updated_at", headerName: "Updated At" }, { field: "request", headerName: "Request" }, ]; diff --git a/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx b/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx index b13564b..438c45b 100644 --- a/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx +++ b/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx @@ -4,6 +4,7 @@ import { Dialog, DialogActions, DialogContent, + DialogContentText, DialogTitle, Grid, Link, @@ -21,6 +22,7 @@ import { import { OrderStatus } from "api/types"; import React, { useState } from "react"; import Container from "@material-ui/core/Container"; +import { useHistory } from "react-router-dom"; import styles from "components/general/OrderTables/OrderTables.module.scss"; import hardwareImagePlaceholder from "assets/images/placeholders/no-hardware-image.svg"; import MenuItem from "@material-ui/core/MenuItem"; @@ -32,6 +34,7 @@ import { import { Formik, FormikValues } from "formik"; import { useDispatch, useSelector } from "react-redux"; import { + clearError, getCreditsUsedSelector, isLoadingSelector, pendingOrdersSelector, @@ -40,6 +43,8 @@ import { } from "slices/order/teamOrderSlice"; import { hardwareSelectors } from "slices/hardware/hardwareSlice"; import { teamStartingCreditsSelector } from "slices/event/teamDetailSlice"; +import { userSelector } from "slices/users/userSlice"; // get current user to track who is packing +import { AppDispatch } from "slices/store"; const createDropdownList = (number: number) => { let entry = []; @@ -68,7 +73,8 @@ const setInitialValues = ( }; export const TeamPendingOrderTable = () => { - const dispatch = useDispatch(); + const dispatch = useDispatch(); + const history = useHistory(); const orders = useSelector(pendingOrdersSelector); const hardware = useSelector(hardwareSelectors.selectEntities); const isLoading = useSelector(isLoadingSelector); @@ -79,6 +85,11 @@ export const TeamPendingOrderTable = () => { const [showRejectDialog, setShowRejectDialog] = useState(false); const [cancelMsg, setCancelMsg] = useState(""); const [selectedOrderId, setSelectedOrderId] = useState(null); + const currentUser = useSelector(userSelector); // get current user to check if they're packing + + // Concurrency error dialog state + const [showConcurrencyError, setShowConcurrencyError] = useState(false); + const [concurrencyErrorMsg, setConcurrencyErrorMsg] = useState(""); const [selectedQuantities, setSelectedQuantities] = useState< Record @@ -102,7 +113,14 @@ export const TeamPendingOrderTable = () => { setVisibility(!visibility); }; - const updateOrder = ( + // Handle closing the concurrency error dialog and redirect to orders page + const handleConcurrencyErrorClose = () => { + setShowConcurrencyError(false); + setConcurrencyErrorMsg(""); + history.push("/orders"); + }; + + const updateOrder = async ( orderId: number, status: OrderStatus, values: FormikValues | null = null, @@ -134,7 +152,20 @@ export const TeamPendingOrderTable = () => { updateOrderData.request = request; } - dispatch(updateOrderStatus(updateOrderData)); + try { + await dispatch(updateOrderStatus(updateOrderData)).unwrap(); + } catch (error: unknown) { + // Show concurrency error dialog for "In Progress" status changes (Start Packing) + if (status === "In Progress") { + // Clear the slice error state so TeamDetail doesn't show AlertBox + dispatch(clearError()); + setConcurrencyErrorMsg( + "Another admin might be packing this order. Please refresh the page." + ); + setShowConcurrencyError(true); + } + // For other status changes, the snackbar from the thunk will handle the error + } }; return ( @@ -188,6 +219,23 @@ export const TeamPendingOrderTable = () => { component={Paper} elevation={2} square={true} + style={{ + // highlight orders being packed by current user + border: + pendingOrder.status === + "In Progress" && + pendingOrder.packing_admin_id === + currentUser?.id + ? "3px solid #ffa000" + : "none", + backgroundColor: + pendingOrder.status === + "In Progress" && + pendingOrder.packing_admin_id === + currentUser?.id + ? "#fff9f0" + : "inherit", + }} > { - {pendingOrder.status === - "Submitted" && ( + {(pendingOrder.status === + "Submitted" || + pendingOrder.status === + "In Progress") && ( { } - {pendingOrder.status === - "Submitted" && ( + {(pendingOrder.status === + "Submitted" || + pendingOrder.status === + "In Progress") && (
{ )} - {pendingOrder.status === - "Submitted" && ( + {(pendingOrder.status === + "Submitted" || + pendingOrder.status === + "In Progress") && ( { {pendingOrder.status === "Submitted" && ( - - - + <> + + + + + + + + )} + {/* show different buttons when order is being packed */} + {pendingOrder.status === "In Progress" && ( + <> + {/* check if current user is the one packing this order */} + {pendingOrder.packing_admin_id === + currentUser?.id ? ( + <> + + + You are currently + packing this order + + + + + + + + + + ) : ( + + + {pendingOrder.packing_admin_name || + "Another admin"}{" "} + is currently packing + this order + + + )} + )} {pendingOrder.status === "Ready for Pickup" && ( @@ -534,48 +720,6 @@ export const TeamPendingOrderTable = () => { )} - {pendingOrder.status === "Submitted" && ( - - - - )} {pendingOrder.status === "Ready for Pickup" && ( @@ -655,6 +799,34 @@ export const TeamPendingOrderTable = () => { + + {/* Concurrency Error Dialog - shown when another admin already claimed the order */} + + + Order Already Being Packed + + + + {concurrencyErrorMsg || + "Another admin is already packing this order. Please select a different order."} + + + + + + ); }; diff --git a/hackathon_site/dashboard/frontend/src/pages/Cart/Cart.tsx b/hackathon_site/dashboard/frontend/src/pages/Cart/Cart.tsx index 6a312b4..b482ce0 100644 --- a/hackathon_site/dashboard/frontend/src/pages/Cart/Cart.tsx +++ b/hackathon_site/dashboard/frontend/src/pages/Cart/Cart.tsx @@ -9,6 +9,7 @@ import LinearProgress from "@material-ui/core/LinearProgress"; import Header from "components/general/Header/Header"; import CartCard from "components/cart/CartCard/CartCard"; import CartSummary from "components/cart/CartSummary/CartSummary"; +import OrderLockAlert from "components/general/OrderLockAlert/OrderLockAlert"; import { clearFilters, getHardwareWithFilters, @@ -19,6 +20,7 @@ import { import { RootState } from "slices/store"; import { cartSelectors, cartTotalSelector } from "slices/hardware/cartSlice"; import { getCategories } from "slices/hardware/categorySlice"; +import { fetchLockStatus } from "slices/hardware/orderLockSlice"; import CartErrorBox from "components/cart/CartErrorBox/CartErrorBox"; import { getCurrentTeam } from "slices/event/teamSlice"; import { getTeamOrders } from "slices/order/orderSlice"; @@ -39,6 +41,7 @@ const Cart = () => { useEffect(() => { dispatch(getCurrentTeam()); dispatch(getTeamOrders()); + dispatch(fetchLockStatus()); }, [dispatch]); useEffect(() => { @@ -72,6 +75,7 @@ const Cart = () => { <>
Cart + diff --git a/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx b/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx index 96449f9..abce97d 100644 --- a/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx +++ b/hackathon_site/dashboard/frontend/src/pages/Orders/Orders.tsx @@ -6,6 +6,7 @@ import OrdersSearch from "components/orders/OrdersSearch/OrdersSearch"; import OrdersFilterButton from "components/orders/OrdersFilterButton/OrdersFilterButton"; import OrdersCount from "components/orders/OrdersCount/OrdersCount"; import OrdersFilter from "components/orders/OrdersFilter/OrderFilter"; +import OrderLockButton from "components/orders/OrderLockButton/OrderLockButton"; import CloseIcon from "@material-ui/icons/Close"; import IconButton from "@material-ui/core/IconButton"; import styles from "./Orders.module.scss"; @@ -15,6 +16,7 @@ import { getOrderStatusCounts, getOrdersWithFilters, } from "slices/order/adminOrderSlice"; +import { fetchLockStatus } from "slices/hardware/orderLockSlice"; import { OrdersTable } from "components/orders/OrdersTable/OrdersTable"; const Orders = () => { @@ -29,6 +31,7 @@ const Orders = () => { // dispatch(clearFilters()); // Filter Bug Fix dispatch(getOrderStatusCounts()); // Filter Bug Fix dispatch(getOrdersWithFilters()); + dispatch(fetchLockStatus()); }, [dispatch]); return ( @@ -80,6 +83,7 @@ const Orders = () => { /> +
diff --git a/hackathon_site/dashboard/frontend/src/slices/hardware/cartSlice.ts b/hackathon_site/dashboard/frontend/src/slices/hardware/cartSlice.ts index 9e44436..b72cde9 100644 --- a/hackathon_site/dashboard/frontend/src/slices/hardware/cartSlice.ts +++ b/hackathon_site/dashboard/frontend/src/slices/hardware/cartSlice.ts @@ -6,7 +6,7 @@ import { PayloadAction, Update, } from "@reduxjs/toolkit"; -import { AppDispatch, RootState } from "slices/store"; +import { RootState } from "slices/store"; import { CartItem } from "api/types"; import { post } from "api/api"; import { push } from "connected-react-router"; @@ -57,7 +57,7 @@ export interface OrderResponse { export const submitOrder = createAsyncThunk< OrderResponse, void, - { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } + { state: RootState; rejectValue: RejectValue } >( `${cartReducerName}/submitOrder`, async (_, { dispatch, getState, rejectWithValue }) => { diff --git a/hackathon_site/dashboard/frontend/src/slices/hardware/orderLockSlice.ts b/hackathon_site/dashboard/frontend/src/slices/hardware/orderLockSlice.ts new file mode 100644 index 0000000..b6f5e8a --- /dev/null +++ b/hackathon_site/dashboard/frontend/src/slices/hardware/orderLockSlice.ts @@ -0,0 +1,111 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { get, post } from "api/api"; +import { OrderLockStatus } from "api/types"; +import { RootState } from "slices/store"; + +export const orderLockReducerName = "orderLock"; + +interface OrderLockState { + lockStatus: OrderLockStatus; + isLoading: boolean; + error: string | null; +} + +const initialState: OrderLockState = { + lockStatus: { + orders_locked: false, + locked_by: null, + locked_at: null, + reason: "", + }, + isLoading: false, + error: null, +}; + +interface RejectValue { + status: number; + message: any; +} + +export const fetchLockStatus = createAsyncThunk< + OrderLockStatus, + void, + { state: RootState; rejectValue: RejectValue } +>(`${orderLockReducerName}/fetchLockStatus`, async (_, { rejectWithValue }) => { + try { + const response = await get("/api/hardware/orders/lock/"); + return response.data; + } catch (e: any) { + return rejectWithValue({ + status: e.response?.status || 500, + message: e.response?.data || "Failed to fetch lock status", + }); + } +}); + +export const toggleLock = createAsyncThunk< + OrderLockStatus, + { orders_locked: boolean; reason?: string }, + { state: RootState; rejectValue: RejectValue } +>(`${orderLockReducerName}/toggleLock`, async (lockData, { rejectWithValue }) => { + try { + const response = await post( + "/api/hardware/orders/lock/", + lockData + ); + return response.data; + } catch (e: any) { + return rejectWithValue({ + status: e.response?.status || 500, + message: e.response?.data || "Failed to toggle lock status", + }); + } +}); + +const orderLockSlice = createSlice({ + name: orderLockReducerName, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchLockStatus.pending, (state) => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(fetchLockStatus.fulfilled, (state, { payload }) => { + state.isLoading = false; + state.lockStatus = payload; + state.error = null; + }); + builder.addCase(fetchLockStatus.rejected, (state, { payload }) => { + state.isLoading = false; + state.error = payload?.message || "Failed to fetch lock status"; + }); + + builder.addCase(toggleLock.pending, (state) => { + state.isLoading = true; + state.error = null; + }); + builder.addCase(toggleLock.fulfilled, (state, { payload }) => { + state.isLoading = false; + state.lockStatus = payload; + state.error = null; + }); + builder.addCase(toggleLock.rejected, (state, { payload }) => { + state.isLoading = false; + state.error = payload?.message || "Failed to toggle lock status"; + }); + }, +}); + +export const { reducer } = orderLockSlice; + +// Selectors +export const lockStatusSelector = (state: RootState) => + state[orderLockReducerName].lockStatus; +export const isLoadingSelector = (state: RootState) => + state[orderLockReducerName].isLoading; +export const errorSelector = (state: RootState) => state[orderLockReducerName].error; +export const ordersLockedSelector = (state: RootState) => + state[orderLockReducerName].lockStatus.orders_locked; + +export default reducer; diff --git a/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts b/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts index 225012d..4ec43c7 100644 --- a/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts +++ b/hackathon_site/dashboard/frontend/src/slices/order/adminOrderSlice.ts @@ -12,6 +12,7 @@ import { displaySnackbar } from "slices/ui/uiSlice"; interface numStatuses { Submitted?: number; + "In Progress"?: number; // new status for tracking order packing "Ready for Pickup"?: number; "Picked Up"?: number; Cancelled?: number; @@ -177,6 +178,10 @@ const adminOrderSlice = createSlice({ "Submitted", payload.results ); + state.numStatuses["In Progress"] = numOrdersByStatus( + "In Progress", + payload.results + ); state.numStatuses["Ready for Pickup"] = numOrdersByStatus( "Ready for Pickup", payload.results @@ -217,6 +222,11 @@ const adminOrderSlice = createSlice({ "Submitted", payload.results ); + // count orders currently being packed by admins + state.numStatuses["In Progress"] = numOrdersByStatus( + "In Progress", + payload.results + ); state.numStatuses["Ready for Pickup"] = numOrdersByStatus( "Ready for Pickup", payload.results diff --git a/hackathon_site/dashboard/frontend/src/slices/order/teamOrderSlice.ts b/hackathon_site/dashboard/frontend/src/slices/order/teamOrderSlice.ts index e2a46ea..7b8e5b4 100644 --- a/hackathon_site/dashboard/frontend/src/slices/order/teamOrderSlice.ts +++ b/hackathon_site/dashboard/frontend/src/slices/order/teamOrderSlice.ts @@ -5,7 +5,7 @@ import { createSlice, } from "@reduxjs/toolkit"; import { OrderStatus } from "api/types"; -import { AppDispatch, RootState } from "slices/store"; +import { RootState } from "slices/store"; import { get, post, patch } from "api/api"; import { APIListResponse, @@ -59,7 +59,7 @@ interface RejectValue { export const getAdminTeamOrders = createAsyncThunk< APIListResponse, string, - { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } + { state: RootState; rejectValue: RejectValue } >( `${teamOrderReducerName}/getAdminTeamOrders`, async (team_code, { rejectWithValue, dispatch }) => { @@ -114,7 +114,7 @@ export interface ReturnOrderResponse { export const returnItems = createAsyncThunk< ReturnOrderResponse, ReturnOrderRequest, - { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } + { state: RootState; rejectValue: RejectValue } >( `${teamOrderReducerName}/returnItems`, async (returnItemsData, { rejectWithValue, dispatch }) => { @@ -157,7 +157,7 @@ export const returnItems = createAsyncThunk< export const updateOrderStatus = createAsyncThunk< Order, UpdateOrderAttributes, - { state: RootState; rejectValue: RejectValue; dispatch: AppDispatch } + { state: RootState; rejectValue: RejectValue } >( `${teamOrderReducerName}/updateOrderStatus`, async (updateOrderData, { rejectWithValue, dispatch }) => { @@ -182,14 +182,19 @@ export const updateOrderStatus = createAsyncThunk< e.response.statusText === "Not Found" ? `Could not update order status: Error ${e.response.status}` : `Something went wrong: Error ${e.response.status}`; - dispatch( - displaySnackbar({ - message, - options: { - variant: "error", - }, - }) - ); + const isConcurrencyError = + updateOrderData.status === "In Progress" && e.response.status === 400; + + if (!isConcurrencyError) { + dispatch( + displaySnackbar({ + message, + options: { + variant: "error", + }, + }) + ); + } return rejectWithValue({ status: e.response.status, message: e.response.message ?? e.response.data, @@ -201,7 +206,11 @@ export const updateOrderStatus = createAsyncThunk< const teamOrderSlice = createSlice({ name: teamOrderReducerName, initialState, - reducers: {}, + reducers: { + clearError: (state) => { + state.error = null; + }, + }, extraReducers: (builder) => { builder.addCase(getAdminTeamOrders.pending, (state) => { state.isLoading = true; @@ -356,6 +365,8 @@ const teamOrderSlice = createSlice({ changes: { status: payload.status, hardwareInTableRow, + packing_admin_id: payload.packing_admin_id, + packing_admin_name: payload.packing_admin_name, }, }; } else { @@ -363,13 +374,26 @@ const teamOrderSlice = createSlice({ id: payload.id, changes: { status: payload.status, + packing_admin_id: payload.packing_admin_id, + packing_admin_name: payload.packing_admin_name, }, }; } teamOrders.updateOne(state, updateObject); }); - builder.addCase(updateOrderStatus.rejected, (state, { payload }) => { + builder.addCase(updateOrderStatus.rejected, (state, { payload, meta }) => { state.isLoading = false; + + // Retrieve the arguments used in the dispatch + const { status } = meta.arg; + + // If it's a concurrency error (status 400) when trying to "Start Packing" (In Progress), + // do NOT set the global error state. This allows the component (TeamPendingOrderTable) + // to remain mounted and show its own concurrency error dialog. + if (status === "In Progress" && payload?.status === 400) { + return; + } + state.error = payload?.message ?? "There was a problem retrieving orders. If this continues please contact hackathon organizers."; @@ -378,6 +402,7 @@ const teamOrderSlice = createSlice({ }); export const { actions, reducer } = teamOrderSlice; +export const { clearError } = actions; export default reducer; // Selectors @@ -410,7 +435,9 @@ export const pendingOrdersSelector = createSelector( (orders) => orders.filter( (order) => - order.status === "Submitted" || order.status === "Ready for Pickup" + order.status === "Submitted" || + order.status === "In Progress" || + order.status === "Ready for Pickup" ) ); diff --git a/hackathon_site/dashboard/frontend/src/slices/store.ts b/hackathon_site/dashboard/frontend/src/slices/store.ts index 68798bd..fdcdb5e 100644 --- a/hackathon_site/dashboard/frontend/src/slices/store.ts +++ b/hackathon_site/dashboard/frontend/src/slices/store.ts @@ -23,6 +23,7 @@ import hardware3dReducer, { import orderReducer, { orderReducerName } from "slices/order/orderSlice"; import categoryReducer, { categoryReducerName } from "slices/hardware/categorySlice"; import cartReducer, { cartReducerName } from "slices/hardware/cartSlice"; +import orderLockReducer, { orderLockReducerName } from "slices/hardware/orderLockSlice"; import teamReducer, { teamReducerName } from "slices/event/teamSlice"; import teamAdminReducer, { teamAdminReducerName } from "slices/event/teamAdminSlice"; import teamDetailReducer, { teamDetailReducerName } from "slices/event/teamDetailSlice"; @@ -33,6 +34,7 @@ export const history = createBrowserHistory(); const reducers = { [cartReducerName]: cartReducer, + [orderLockReducerName]: orderLockReducer, [teamReducerName]: teamReducer, [teamOrderReducerName]: teamOrderReducer, [teamDetailReducerName]: teamDetailReducer, diff --git a/hackathon_site/hardware/admin.py b/hackathon_site/hardware/admin.py index f3fef2c..e97e6a9 100644 --- a/hackathon_site/hardware/admin.py +++ b/hackathon_site/hardware/admin.py @@ -13,7 +13,7 @@ from import_export.widgets import ManyToManyWidget from import_export.fields import Field -from hardware.models import Hardware, Category, Order, Incident, OrderItem +from hardware.models import Hardware, Category, Order, Incident, OrderItem, OrderLockConfig class OrderInline(admin.TabularInline): @@ -330,3 +330,30 @@ def get_team_code(self, obj: Incident): return ( obj.order_item.order.team.team_code if obj.order_item.order.team else None ) + + +@admin.register(OrderLockConfig) +class OrderLockConfigAdmin(admin.ModelAdmin): + list_display = ("orders_locked", "locked_by", "locked_at", "updated_at") + readonly_fields = ("locked_by", "locked_at", "created_at", "updated_at") + fieldsets = ( + (None, { + "fields": ("orders_locked", "reason") + }), + ("Lock Information", { + "fields": ("locked_by", "locked_at"), + "classes": ("collapse",) + }), + ("Timestamps", { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",) + }), + ) + + def has_add_permission(self, request): + # Only allow one instance (singleton pattern) + return not OrderLockConfig.objects.exists() + + def has_delete_permission(self, request, obj=None): + # Don't allow deletion of the singleton instance + return False diff --git a/hackathon_site/hardware/api_urls.py b/hackathon_site/hardware/api_urls.py index 2323350..3d673e9 100644 --- a/hackathon_site/hardware/api_urls.py +++ b/hackathon_site/hardware/api_urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("hardware/", views.HardwareListView.as_view(), name="hardware-list"), path("orders/returns/", views.OrderItemReturnView.as_view(), name="order-return"), + path("orders/lock/", views.OrderLockView.as_view(), name="order-lock"), path("orders/", views.OrderListView.as_view(), name="order-list"), path("categories/", views.CategoryListView.as_view(), name="category-list"), path("incidents/", views.IncidentListView.as_view(), name="incident-list"), diff --git a/hackathon_site/hardware/migrations/0015_order_in_progress_status.py b/hackathon_site/hardware/migrations/0015_order_in_progress_status.py new file mode 100644 index 0000000..6000124 --- /dev/null +++ b/hackathon_site/hardware/migrations/0015_order_in_progress_status.py @@ -0,0 +1,46 @@ +# Generated manually for order packing improvements + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('hardware', '0014_set_default_max_item_count'), + ] + + operations = [ + # add new "In Progress" status option to existing status choices + migrations.AlterField( + model_name='order', + name='status', + field=models.CharField( + choices=[ + ('Submitted', 'Submitted'), + ('In Progress', 'In Progress'), + ('Ready for Pickup', 'Ready for Pickup'), + ('Picked Up', 'Picked Up'), + ('Cancelled', 'Cancelled'), + ('Returned', 'Returned') + ], + default='Submitted', + max_length=64 + ), + ), + # add packing_admin field to track who is currently packing the order + migrations.AddField( + model_name='order', + name='packing_admin', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='packing_orders', + to=settings.AUTH_USER_MODEL + ), + ), + ] + diff --git a/hackathon_site/hardware/migrations/0016_orderlockconfig.py b/hackathon_site/hardware/migrations/0016_orderlockconfig.py new file mode 100644 index 0000000..199e325 --- /dev/null +++ b/hackathon_site/hardware/migrations/0016_orderlockconfig.py @@ -0,0 +1,34 @@ +# Generated manually for order lock system +# Migration for hardware app + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('hardware', '0015_order_in_progress_status'), + ] + + operations = [ + migrations.CreateModel( + name='OrderLockConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('orders_locked', models.BooleanField(default=False)), + ('locked_at', models.DateTimeField(blank=True, null=True)), + ('reason', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('locked_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_locks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Order Lock Configuration', + 'verbose_name_plural': 'Order Lock Configuration', + }, + ), + ] + diff --git a/hackathon_site/hardware/models.py b/hackathon_site/hardware/models.py index cb5ac61..54c9c2e 100644 --- a/hackathon_site/hardware/models.py +++ b/hackathon_site/hardware/models.py @@ -1,8 +1,11 @@ from django.db import models from django.db.models import Count, F, Q, Sum +from django.contrib.auth import get_user_model from event.models import Team as TeamEvent +User = get_user_model() + class Category(models.Model): class Meta: @@ -109,6 +112,7 @@ def __str__(self): class Order(models.Model): STATUS_CHOICES = [ ("Submitted", "Submitted"), + ("In Progress", "In Progress"), # new status to track orders being packed ("Ready for Pickup", "Ready for Pickup"), ("Picked Up", "Picked Up"), ("Cancelled", "Cancelled"), @@ -121,6 +125,10 @@ class Order(models.Model): max_length=64, choices=STATUS_CHOICES, default="Submitted" ) request = models.JSONField(null=False) + # track which admin is currently packing this order to prevent double-packing + packing_admin = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, related_name="packing_orders" + ) created_at = models.DateTimeField(auto_now_add=True, null=False) updated_at = models.DateTimeField(auto_now=True, null=False) @@ -166,3 +174,37 @@ class Incident(models.Model): def __str__(self): return f"{self.id}" + + +class OrderLockConfig(models.Model): + """ + Control whether order submissions are locked. + Admins can toggle this to prevent/allow order submissions without redeployment. + Superusers can bypass the lock for emergency situations. + """ + orders_locked = models.BooleanField(default=False) + locked_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, related_name="order_locks" + ) + locked_at = models.DateTimeField(null=True, blank=True) + reason = models.TextField(blank=True, default="") + + created_at = models.DateTimeField(auto_now_add=True, null=False) + updated_at = models.DateTimeField(auto_now=True, null=False) + + class Meta: + verbose_name = "Order Lock Configuration" + verbose_name_plural = "Order Lock Configuration" + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def get_lock_status(cls): + obj, created = cls.objects.get_or_create(pk=1) + return obj + + def __str__(self): + status = "Locked" if self.orders_locked else "Unlocked" + return f"Order Submissions: {status}" \ No newline at end of file diff --git a/hackathon_site/hardware/serializers.py b/hackathon_site/hardware/serializers.py index 7882589..ee7ebb3 100644 --- a/hackathon_site/hardware/serializers.py +++ b/hackathon_site/hardware/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from event.models import Profile -from hardware.models import Hardware, Category, OrderItem, Order, Incident +from hardware.models import Hardware, Category, OrderItem, Order, Incident, OrderLockConfig class HardwareSerializer(serializers.ModelSerializer): @@ -142,6 +142,8 @@ class OrderListSerializer(serializers.ModelSerializer): items = OrderItemInOrderSerializer(many=True, read_only=True) team_code = serializers.SerializerMethodField() total_credits = serializers.SerializerMethodField() # Add total_credits field + packing_admin_id = serializers.SerializerMethodField() # track who is packing this order + packing_admin_name = serializers.SerializerMethodField() # display admin name for ui class Meta: model = Order @@ -155,6 +157,8 @@ class Meta: "updated_at", "request", "total_credits", # Include total_credits in API response + "packing_admin_id", + "packing_admin_name", ) @staticmethod @@ -164,6 +168,16 @@ def get_team_code(obj: Order): def get_total_credits(self, obj): # Directly use the model method return obj.get_total_credits() + + def get_packing_admin_id(self, obj): + # return the id of the admin currently packing this order + return obj.packing_admin.id if obj.packing_admin else None + + def get_packing_admin_name(self, obj): + # return full name of admin packing the order for display purposes + if obj.packing_admin: + return f"{obj.packing_admin.first_name} {obj.packing_admin.last_name}".strip() or obj.packing_admin.username + return None class OrderChangeSerializer(OrderListSerializer): @@ -172,8 +186,10 @@ class OrderChangeSerializer(OrderListSerializer): required=False, allow_blank=True, write_only=True ) + # updated status transitions to support the new "In Progress" packing state change_options = { - "Submitted": ["Cancelled", "Ready for Pickup"], + "Submitted": ["Cancelled", "In Progress"], + "In Progress": ["Submitted", "Ready for Pickup", "Cancelled"], "Ready for Pickup": ["Picked Up", "Submitted"], "Picked Up": ["Returned"], } @@ -207,13 +223,24 @@ def validate_status(self, data): def update(self, instance: Order, validated_data): # Remove the optional cancellation message from the validated data - # so it isn’t passed to the model update. + # so it isn't passed to the model update. cancellation_message = validated_data.pop("cancellation_message", None) status = validated_data.pop("status", None) request_field = validated_data.pop("request", None) if status is not None: instance.status = status + + # automatically manage packing_admin based on status changes + if status == "In Progress" and instance.packing_admin is None: + # when starting to pack an order, assign current user as packing admin + request = self.context.get("request") + if request and request.user: + instance.packing_admin = request.user + elif status in ["Ready for Pickup", "Cancelled", "Submitted"]: + # clear packing admin when order is no longer being packed + instance.packing_admin = None + if request_field is not None: for item in request_field: items_in_order = list( @@ -290,11 +317,20 @@ def merge_requests(hardware_requests): # check that the requests are within per-team constraints def validate(self, data): - if ( - not self.context["request"] - .user.groups.filter(name=settings.TEST_USER_GROUP) - .exists() - ): + user = self.context["request"].user + is_test_user = user.groups.filter(name=settings.TEST_USER_GROUP).exists() + is_superuser = user.is_superuser + + # Allow test users and superusers to bypass all restrictions + if not is_test_user and not is_superuser: + # Check lock status first + lock_config = OrderLockConfig.get_lock_status() + if lock_config.orders_locked: + raise serializers.ValidationError( + "Order submissions are currently locked by administrators. " + "Please contact the hardware team for assistance." + ) + # time restrictions if datetime.now(settings.TZ_INFO) < settings.HARDWARE_SIGN_OUT_START_DATE: raise serializers.ValidationError( diff --git a/hackathon_site/hardware/test_api.py b/hackathon_site/hardware/test_api.py index 2b52522..0df0fae 100644 --- a/hackathon_site/hardware/test_api.py +++ b/hackathon_site/hardware/test_api.py @@ -10,7 +10,7 @@ from rest_framework.test import APITestCase from event.models import Team, User, Profile -from hardware.models import Hardware, Category, Order, OrderItem, Incident +from hardware.models import Hardware, Category, Order, OrderItem, Incident, OrderLockConfig from hardware.serializers import ( HardwareSerializer, CategorySerializer, @@ -2045,3 +2045,140 @@ def test_incorrect_permissions(self): # del final_response["id"] # for attribute in similar_attributes: # self.assertEqual(final_response[attribute], self.request_data[attribute]) + + +class OrderLockViewTestCase(SetupUserMixin, APITestCase): + def setUp(self): + super().setUp() + self.view = reverse("api:hardware:order-lock") + self.admin_permissions = Permission.objects.filter( + content_type__app_label="hardware", codename__in=["view_order", "change_order"] + ) + # Ensure lock config starts in unlocked state + lock_config = OrderLockConfig.get_lock_status() + lock_config.orders_locked = False + lock_config.save() + + def test_get_lock_status_not_logged_in(self): + response = self.client.get(self.view) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_get_lock_status_success(self): + self._login() + response = self.client.get(self.view) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("orders_locked", data) + self.assertIn("locked_by", data) + self.assertIn("locked_at", data) + self.assertIn("reason", data) + self.assertFalse(data["orders_locked"]) + + def test_toggle_lock_requires_admin(self): + self._login() + request_data = {"orders_locked": True} + response = self.client.post(self.view, request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_toggle_lock_success(self): + # Create admin user + admin_group = Group.objects.get(name="Hardware Site Admins") + self.user.groups.add(admin_group) + self._login() + + # Lock orders + request_data = {"orders_locked": True, "reason": "Test lock"} + response = self.client.post(self.view, request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertTrue(data["orders_locked"]) + + # Verify in database + lock_config = OrderLockConfig.get_lock_status() + self.assertTrue(lock_config.orders_locked) + self.assertEqual(lock_config.locked_by, self.user) + self.assertIsNotNone(lock_config.locked_at) + + # Unlock orders + request_data = {"orders_locked": False} + response = self.client.post(self.view, request_data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertFalse(data["orders_locked"]) + + # Verify in database + lock_config.refresh_from_db() + self.assertFalse(lock_config.orders_locked) + self.assertIsNone(lock_config.locked_by) + + +class OrderSubmissionWithLockTestCase(SetupUserMixin, APITestCase): + def setUp(self): + super().setUp() + self.view = reverse("api:hardware:order-list") + self.hardware = Hardware.objects.create( + name="Test Hardware", + model_number="model", + manufacturer="manufacturer", + datasheet="/datasheet/location/", + quantity_available=10, + max_per_team=5, + picture="/picture/location", + ) + # Ensure lock config starts in unlocked state + lock_config = OrderLockConfig.get_lock_status() + lock_config.orders_locked = False + lock_config.save() + + def test_order_submission_when_locked(self): + """Test that regular users cannot submit orders when locked""" + self._login() + self.create_min_number_of_profiles() + + # Lock orders + lock_config = OrderLockConfig.get_lock_status() + lock_config.orders_locked = True + lock_config.save() + + request_data = {"hardware": [{"id": self.hardware.id, "quantity": 1}]} + response = self.client.post(self.view, request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response.json()) + self.assertIn("locked by administrators", response.json()["non_field_errors"][0]) + + @override_settings( + HARDWARE_SIGN_OUT_START_DATE=datetime.now(settings.TZ_INFO) - relativedelta(days=1), + HARDWARE_SIGN_OUT_END_DATE=datetime.now(settings.TZ_INFO) + relativedelta(days=1), + ) + def test_order_submission_when_unlocked(self): + """Test that users can submit orders when unlocked""" + self._login() + self.create_min_number_of_profiles() + + # Ensure unlocked + lock_config = OrderLockConfig.get_lock_status() + lock_config.orders_locked = False + lock_config.save() + + request_data = {"hardware": [{"id": self.hardware.id, "quantity": 1}]} + response = self.client.post(self.view, request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_superuser_can_bypass_lock(self): + """Test that superusers can submit orders even when locked""" + self._login() + self.user.is_superuser = True + self.user.save() + self.create_min_number_of_profiles() + + # Lock orders + lock_config = OrderLockConfig.get_lock_status() + lock_config.orders_locked = True + lock_config.save() + + request_data = {"hardware": [{"id": self.hardware.id, "quantity": 1}]} + response = self.client.post(self.view, request_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/hackathon_site/hardware/views.py b/hackathon_site/hardware/views.py index e794674..1506a6a 100644 --- a/hackathon_site/hardware/views.py +++ b/hackathon_site/hardware/views.py @@ -22,7 +22,7 @@ IncidentFilter, OrderItemFilter, ) -from hardware.models import Hardware, Category, Order, Incident, OrderItem +from hardware.models import Hardware, Category, Order, Incident, OrderItem, OrderLockConfig from hardware.serializers import ( CategorySerializer, @@ -42,6 +42,7 @@ logger = logging.getLogger(__name__) ORDER_STATUS_MSG = { + "In Progress": "is currently being packed!", # new status for tracking order packing "Ready for Pickup": "is Ready for Pickup!", "Picked Up": "has been Picked Up!", "Cancelled": f"was Cancelled by a {settings.HACKATHON_NAME} Exec.", @@ -49,6 +50,7 @@ } ORDER_STATUS_CLOSING_MSG = { + "In Progress": "Your order is currently being prepared by our team. You will receive another notification when it's ready for pickup.", "Ready for Pickup": "After the opening ceremony concludes on February 15th at around 11 am, please make your way to the Hardware Distribution Room at MYHAL 320 to retrieve your order.", "Picked Up": "Take good care of your hardware and Happy Hacking! Remember to return the items when you are finished using them.", "Cancelled": f"A {settings.HACKATHON_NAME} exec will be in contact with you shortly. If you don't hear back from them soon, please go to the Hardware Distribution Room for more information on why your order was cancelled.", @@ -432,3 +434,45 @@ def post(self, request, *args, **kwargs): finally: connection.close() return Response(create_response, status=status.HTTP_201_CREATED) + + +class OrderLockView(generics.GenericAPIView): + """ + API endpoint to get and toggle order submission lock status. + GET: Returns current lock status (accessible to all authenticated users) + POST: Toggles lock status (admin only) + """ + def get_permissions(self): + if self.request.method == "POST": + return [UserIsAdmin()] + return [permissions.IsAuthenticated()] + + def get(self, request, *args, **kwargs): + """Get current lock status""" + lock_config = OrderLockConfig.get_lock_status() + return Response({ + "orders_locked": lock_config.orders_locked, + "locked_by": lock_config.locked_by.email if lock_config.locked_by else None, + "locked_at": lock_config.locked_at, + "reason": lock_config.reason, + }) + + def post(self, request, *args, **kwargs): + """Toggle lock status (admin only)""" + from django.utils import timezone + + lock_config = OrderLockConfig.get_lock_status() + new_lock_state = request.data.get("orders_locked", False) + + lock_config.orders_locked = new_lock_state + lock_config.locked_by = request.user if new_lock_state else None + lock_config.locked_at = timezone.now() if new_lock_state else None + lock_config.reason = request.data.get("reason", "") + lock_config.save() + + return Response({ + "orders_locked": lock_config.orders_locked, + "locked_by": lock_config.locked_by.email if lock_config.locked_by else None, + "locked_at": lock_config.locked_at, + "reason": lock_config.reason, + })