diff --git a/.gitignore b/.gitignore index f83c883..e519c97 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,4 @@ node_modules/ # analytics hackathon_site/registration/analytics/data/ +hackathon_site/Hardware Counting 2025 MakeUofT - Hardware Count Overview.csv 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 e5fe9b6..6c0179d 100644 --- a/hackathon_site/dashboard/frontend/src/api/types.ts +++ b/hackathon_site/dashboard/frontend/src/api/types.ts @@ -102,12 +102,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" @@ -131,6 +131,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"; @@ -156,6 +158,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 }; @@ -198,3 +202,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..ca4651d 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,7 @@ 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 { ordersLockedSelector } from "slices/hardware/orderLockSlice"; import { hardwareSignOutEndDate, hardwareSignOutStartDate, @@ -33,6 +34,7 @@ const CartSummary = () => { const subtotalCredits = useSelector(subtotalCreditsSelector); const creditsUsed = useSelector(getCreditsUsedSelector); const creditsAvailable = useSelector(teamSelector)?.credits; + const ordersLocked = useSelector(ordersLockedSelector); const projectedCredits = creditsAvailable ? creditsAvailable - creditsUsed - subtotalCredits : 0; @@ -84,6 +86,7 @@ const CartSummary = () => { (projectDescription && projectDescription.length < minProjectDescriptionLength) || (!isTestUser && isOutsideSignOutPeriod) || + (!isTestUser && ordersLocked) || projectedCredits < 0 } onClick={onSubmit} 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 f67103b..87f14a6 100644 --- a/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx +++ b/hackathon_site/dashboard/frontend/src/components/orders/OrdersFilter/OrderFilter.tsx @@ -89,6 +89,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..5e47c04 100644 --- a/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx +++ b/hackathon_site/dashboard/frontend/src/components/teamDetail/TeamPendingOrderTable/TeamPendingOrderTable.tsx @@ -40,6 +40,7 @@ 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 const createDropdownList = (number: number) => { let entry = []; @@ -79,6 +80,7 @@ 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 const [selectedQuantities, setSelectedQuantities] = useState< Record @@ -188,6 +190,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" && ( - - - + <> + + + + + + + + )} + {/* 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 +685,6 @@ export const TeamPendingOrderTable = () => { )} - {pendingOrder.status === "Submitted" && ( - - - - )} {pendingOrder.status === "Ready for Pickup" && ( 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 66a0c16..85cdac0 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 { getOrdersWithFilters, clearFilters, } from "slices/order/adminOrderSlice"; +import { fetchLockStatus } from "slices/hardware/orderLockSlice"; import { OrdersTable } from "components/orders/OrdersTable/OrdersTable"; const Orders = () => { @@ -28,6 +30,7 @@ const Orders = () => { useEffect(() => { dispatch(clearFilters()); dispatch(getOrdersWithFilters()); + dispatch(fetchLockStatus()); }, [dispatch]); return ( @@ -79,6 +82,7 @@ const Orders = () => { /> + 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 4444830..2b93f18 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; @@ -139,6 +140,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/store.ts b/hackathon_site/dashboard/frontend/src/slices/store.ts index 0759e73..c0969b9 100644 --- a/hackathon_site/dashboard/frontend/src/slices/store.ts +++ b/hackathon_site/dashboard/frontend/src/slices/store.ts @@ -20,6 +20,7 @@ import hardwareReducer, { hardwareReducerName } from "slices/hardware/hardwareSl 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"; @@ -30,6 +31,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, + })