From 14cea283e1e34492346d227b4e7796aa5485ff5a Mon Sep 17 00:00:00 2001 From: Winston Hsiao <96440583+Winston-Hsiao@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:55:26 -0800 Subject: [PATCH] Admin dashboard for managing orders (#627) --- frontend/src/App.tsx | 6 + .../src/components/admin/AdminManageOrder.tsx | 104 +++++++++++++++++ .../modals/AdminProcessPreorderModal.tsx | 106 +++++++++++++++++ .../modals/AdminUpdateStatusModal.tsx | 109 ++++++++++++++++++ frontend/src/components/nav/navigation.tsx | 10 +- frontend/src/components/orders/OrderCard.tsx | 39 ++++--- .../src/components/pages/AdminDashboard.tsx | 109 ++++++++++++++++++ frontend/src/components/ui/Modal.tsx | 14 ++- frontend/src/gen/api.ts | 109 +++++++++++++++++- frontend/src/lib/types/orders.ts | 7 +- frontend/src/lib/types/routes.ts | 3 + frontend/src/lib/utils/orders.ts | 6 + store/app/crud/orders.py | 31 +++++ store/app/routers/orders.py | 71 ++++++++++-- store/app/routers/robots.py | 2 - store/app/routers/stripe.py | 30 ++--- 16 files changed, 704 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/admin/AdminManageOrder.tsx create mode 100644 frontend/src/components/modals/AdminProcessPreorderModal.tsx create mode 100644 frontend/src/components/modals/AdminUpdateStatusModal.tsx create mode 100644 frontend/src/components/pages/AdminDashboard.tsx create mode 100644 frontend/src/lib/utils/orders.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59fa7492..436fdfc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Navbar from "@/components/nav/Navbar"; import APIKeys from "@/components/pages/APIKeys"; import About from "@/components/pages/About"; import Account from "@/components/pages/Account"; +import AdminDashboard from "@/components/pages/AdminDashboard"; import Browse from "@/components/pages/Browse"; import CreateSell from "@/components/pages/CreateSell"; import CreateShare from "@/components/pages/CreateShare"; @@ -163,6 +164,11 @@ const App = () => { element={} /> + } + /> + {/* Not found */} void; +} + +const AdminManageOrder: React.FC = ({ + order, + onOrderUpdate, +}) => { + const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); + const [isPreorderModalOpen, setIsPreorderModalOpen] = useState(false); + const [isCancelOrderModalOpen, setIsCancelOrderModalOpen] = useState(false); + + const handleOrderUpdate = (updatedOrder: OrderWithProduct) => { + if (onOrderUpdate) { + onOrderUpdate(updatedOrder); + } + }; + + const showPreorderOption = + order.order.preorder_deposit_amount && + order.order.status !== "awaiting_final_payment" && + order.order.status !== "cancelled" && + order.order.status !== "refunded"; + + return ( +
+
+ Admin Controls + + + Manage Order + + + setIsStatusModalOpen(true)} + className="cursor-pointer" + > + Change order status + + {showPreorderOption && ( + <> +
+ setIsPreorderModalOpen(true)} + className="cursor-pointer" + > + Process pre-order + + + )} +
+ setIsCancelOrderModalOpen(true)} + className="cursor-pointer" + > + Cancel order + +
+
+
+ + + + + + +
+ ); +}; + +export default AdminManageOrder; diff --git a/frontend/src/components/modals/AdminProcessPreorderModal.tsx b/frontend/src/components/modals/AdminProcessPreorderModal.tsx new file mode 100644 index 00000000..a9db7306 --- /dev/null +++ b/frontend/src/components/modals/AdminProcessPreorderModal.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; + +import Modal from "@/components/ui/Modal"; +import { Button } from "@/components/ui/button"; +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; +import type { OrderWithProduct } from "@/lib/types/orders"; +import { formatPrice } from "@/lib/utils/formatNumber"; + +interface AdminProcessPreorderModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + order: OrderWithProduct; + onOrderUpdate: (updatedOrder: OrderWithProduct) => void; +} + +const AdminProcessPreorderModal: React.FC = ({ + isOpen, + onOpenChange, + order, + onOrderUpdate, +}) => { + const { api } = useAuthentication(); + const { addAlert, addErrorAlert } = useAlertQueue(); + const [isProcessing, setIsProcessing] = useState(false); + + const handleProcessPreorder = async () => { + setIsProcessing(true); + try { + const { data, error } = await api.client.POST( + "/stripe/process/preorder/{order_id}", + { + params: { + path: { order_id: order.order.id }, + }, + }, + ); + + if (error) { + throw error; + } + + onOrderUpdate({ + ...order, + order: { + ...order.order, + status: "awaiting_final_payment", + final_payment_checkout_session_id: data.checkout_session.id, + }, + }); + + addAlert("Pre-order processed successfully", "success"); + onOpenChange(false); + } catch (error) { + console.error(error); + addErrorAlert("Failed to process pre-order"); + } finally { + setIsProcessing(false); + } + }; + + return ( + onOpenChange(false)} size="xl"> +
+

Process Pre-order

+ +
+

+ Are you sure you want to process this pre-order? This will: +

+
    +
  • Mark the order as ready for final payment
  • +
  • + Request customer to pay remaining balance of{" "} + {formatPrice( + order.order.price_amount - + (order.order.preorder_deposit_amount || 0), + )} +
  • +
  • Notify the customer via email
  • +
+ +
+ + +
+
+
+
+ ); +}; + +export default AdminProcessPreorderModal; diff --git a/frontend/src/components/modals/AdminUpdateStatusModal.tsx b/frontend/src/components/modals/AdminUpdateStatusModal.tsx new file mode 100644 index 00000000..1457b9d8 --- /dev/null +++ b/frontend/src/components/modals/AdminUpdateStatusModal.tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; + +import Modal from "@/components/ui/Modal"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; +import type { OrderWithProduct } from "@/lib/types/orders"; +import { OrderStatus, orderStatuses } from "@/lib/types/orders"; +import { normalizeStatus } from "@/lib/utils/formatString"; + +interface AdminUpdateStatusModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + order: OrderWithProduct; + onOrderUpdate: (updatedOrder: OrderWithProduct) => void; +} + +const AdminUpdateStatusModal: React.FC = ({ + isOpen, + onOpenChange, + order, + onOrderUpdate, +}) => { + const { api } = useAuthentication(); + const { addAlert, addErrorAlert } = useAlertQueue(); + const [selectedStatus, setSelectedStatus] = useState(order.order.status); + const [isUpdating, setIsUpdating] = useState(false); + + const handleUpdateStatus = async () => { + setIsUpdating(true); + try { + const { data, error } = await api.client.PUT( + "/orders/admin/status/{order_id}", + { + params: { + path: { order_id: order.order.id }, + }, + body: { + status: selectedStatus, + }, + }, + ); + + if (error) { + throw error; + } + + onOrderUpdate({ + order: data, + product: order.product, + }); + + addAlert("Order status updated successfully", "success"); + onOpenChange(false); + } catch (error) { + console.error(error); + addErrorAlert("Failed to update order status"); + } finally { + setIsUpdating(false); + } + }; + + return ( + onOpenChange(false)}> +
+

Update Order Status

+ +
+
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +export default AdminUpdateStatusModal; diff --git a/frontend/src/components/nav/navigation.tsx b/frontend/src/components/nav/navigation.tsx index 77fa0e5c..4de49e73 100644 --- a/frontend/src/components/nav/navigation.tsx +++ b/frontend/src/components/nav/navigation.tsx @@ -1,5 +1,5 @@ import { FaRobot, FaTerminal } from "react-icons/fa"; -import { FaRegFileLines } from "react-icons/fa6"; +import { FaChartBar, FaRegFileLines } from "react-icons/fa6"; import ROUTES from "@/lib/types/routes"; @@ -34,6 +34,12 @@ const TERMINAL_NAV_ITEM: BaseNavItem = { icon: , }; +const ADMIN_DASHBOARD_NAV_ITEM: BaseNavItem = { + name: "Admin Dashboard", + path: ROUTES.ADMIN.path, + icon: , +}; + export const AUTHENTICATED_NAV_ITEMS: BaseNavItem[] = []; export const getNavItems = ( @@ -43,7 +49,7 @@ export const getNavItems = ( let navItems = [...DEFAULT_NAV_ITEMS]; if (isAdmin) { - navItems = [TERMINAL_NAV_ITEM, ...navItems]; + navItems = [ADMIN_DASHBOARD_NAV_ITEM, TERMINAL_NAV_ITEM, ...navItems]; } if (isAuthenticated) { diff --git a/frontend/src/components/orders/OrderCard.tsx b/frontend/src/components/orders/OrderCard.tsx index 2713d2c6..8b5db6cc 100644 --- a/frontend/src/components/orders/OrderCard.tsx +++ b/frontend/src/components/orders/OrderCard.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; +import AdminManageOrder from "@/components/admin/AdminManageOrder"; import CancelOrderModal from "@/components/modals/CancelOrderModal"; import EditAddressModal from "@/components/modals/EditAddressModal"; import { @@ -9,14 +10,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { OrderWithProduct } from "@/lib/types/orders"; -import { - activeStatuses, - canModifyStatuses, - orderStatuses, - redStatuses, -} from "@/lib/types/orders"; +import { activeStatuses, orderStatuses, redStatuses } from "@/lib/types/orders"; import { formatPrice } from "@/lib/utils/formatNumber"; import { normalizeStatus } from "@/lib/utils/formatString"; +import { canModifyOrder } from "@/lib/utils/orders"; enum OrderStatus { PREORDER = -1, @@ -27,9 +24,10 @@ enum OrderStatus { DELIVERED = 4, } -const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ - orderWithProduct: initialOrderWithProduct, -}) => { +const OrderCard: React.FC<{ + orderWithProduct: OrderWithProduct; + isAdminView?: boolean; +}> = ({ orderWithProduct: initialOrderWithProduct, isAdminView }) => { const [orderWithProduct, setOrderWithProduct] = useState( initialOrderWithProduct, ); @@ -66,13 +64,16 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ setOrderWithProduct(updatedOrder); }; - const canModifyOrder = () => { - return canModifyStatuses.includes(order.status); - }; - return ( -
-

{product.name}

+
+ {isAdminView ? ( + + ) : null} + +

{product.name}

Status:{" "} @@ -129,17 +130,17 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({ Manage order setIsEditAddressModalOpen(true)} className="cursor-pointer" > @@ -147,7 +148,7 @@ const OrderCard: React.FC<{ orderWithProduct: OrderWithProduct }> = ({

setIsCancelOrderModalOpen(true)} className="cursor-pointer" > diff --git a/frontend/src/components/pages/AdminDashboard.tsx b/frontend/src/components/pages/AdminDashboard.tsx new file mode 100644 index 00000000..6b94a734 --- /dev/null +++ b/frontend/src/components/pages/AdminDashboard.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import OrderCard from "@/components/orders/OrderCard"; +import Spinner from "@/components/ui/Spinner"; +import { useAlertQueue } from "@/hooks/useAlertQueue"; +import { useAuthentication } from "@/hooks/useAuth"; +import { ApiError } from "@/lib/types/api"; +import type { OrderWithProduct } from "@/lib/types/orders"; +import ROUTES from "@/lib/types/routes"; + +const AdminDashboard = () => { + const navigate = useNavigate(); + const { api, currentUser, isAuthenticated, isLoading } = useAuthentication(); + const [orders, setOrders] = useState([]); + const [loadingOrders, setLoadingOrders] = useState(true); + const { addErrorAlert } = useAlertQueue(); + + useEffect(() => { + const fetchOrders = async () => { + if (isAuthenticated && currentUser?.permissions?.includes("is_admin")) { + setLoadingOrders(true); + try { + const { data, error } = await api.client.GET("/orders/admin/all"); + + if (error) { + const apiError = error as ApiError; + addErrorAlert({ + message: "Failed to fetch orders", + detail: apiError.message || "An unexpected error occurred", + }); + setOrders([]); + } else { + setOrders(data.orders as OrderWithProduct[]); + } + } finally { + setLoadingOrders(false); + } + } + }; + + fetchOrders(); + }, [api, currentUser, isAuthenticated]); + + // Redirect non-admin users + useEffect(() => { + if ( + !isLoading && + (!isAuthenticated || !currentUser?.permissions?.includes("is_admin")) + ) { + navigate(ROUTES.HOME.path); + } + }, [isLoading, isAuthenticated, currentUser]); + + if (isLoading || loadingOrders) { + return ( +
+
+

Admin Dashboard

+

+ View and manage all orders across the platform. Update order + statuses, process pre-orders, and cancel orders. +

+
+
+ +
+
+ ); + } + + return ( +
+
+

Admin Dashboard

+

+ View and manage all orders across the platform. Update order statuses, + process pre-orders, and cancel orders. +

+
+ +
+

+ Viewing All Orders As Admin +

+ {orders.length > 0 ? ( +
+ {orders.map((orderWithProduct) => ( + + ))} +
+ ) : ( +
+

No orders found

+

+ There are no orders placed yet or no orders that match your query. +

+
+ )} +
+
+ ); +}; + +export default AdminDashboard; diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 83b18144..dbde01e3 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -4,9 +4,17 @@ interface ModalProps { isOpen: boolean; onClose: () => void; children: React.ReactNode; + className?: string; + size?: "sm" | "md" | "lg" | "xl"; } -const Modal: React.FC = ({ isOpen, onClose, children }) => { +const Modal: React.FC = ({ + isOpen, + onClose, + children, + className, + size = "md", +}) => { if (!isOpen) return null; return ( @@ -15,7 +23,9 @@ const Modal: React.FC = ({ isOpen, onClose, children }) => { className="absolute inset-0 bg-gray-11 opacity-50" onClick={onClose} >
-
+
{children}
diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 6f229f96..35c37369 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -703,6 +703,43 @@ export interface paths { patch?: never; trace?: never; }; + "/orders/admin/all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get All Orders + * @description Get all orders (admin only). + */ + get: operations["get_all_orders_orders_admin_all_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/orders/admin/status/{order_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Order Status */ + put: operations["update_order_status_orders_admin_status__order_id__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/robots/create": { parameters: { query?: never; @@ -979,7 +1016,7 @@ export interface paths { patch?: never; trace?: never; }; - "/stripe/process-preorder/{order_id}": { + "/stripe/process/preorder/{order_id}": { parameters: { query?: never; header?: never; @@ -1226,6 +1263,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AdminOrdersResponse */ + AdminOrdersResponse: { + /** Orders */ + orders: components["schemas"]["OrderWithProduct"][]; + }; /** ArtifactUrls */ ArtifactUrls: { /** Small */ @@ -1753,7 +1795,7 @@ export interface components { /** Status */ status: string; /** Checkout Session */ - checkout_session: Record | null; + checkout_session: Record; }; /** ProductInfo */ ProductInfo: { @@ -1983,6 +2025,14 @@ export interface components { /** Shipping Country */ shipping_country: string; }; + /** UpdateOrderStatusRequest */ + UpdateOrderStatusRequest: { + /** + * Status + * @enum {string} + */ + status: "processing" | "in_development" | "being_assembled" | "shipped" | "delivered" | "preorder_placed" | "awaiting_final_payment" | "cancelled" | "refunded"; + }; /** UpdateRobotRequest */ UpdateRobotRequest: { /** Name */ @@ -3377,6 +3427,61 @@ export interface operations { }; }; }; + get_all_orders_orders_admin_all_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminOrdersResponse"]; + }; + }; + }; + }; + update_order_status_orders_admin_status__order_id__put: { + parameters: { + query?: never; + header?: never; + path: { + order_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateOrderStatusRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_robot_robots_create_post: { parameters: { query?: never; diff --git a/frontend/src/lib/types/orders.ts b/frontend/src/lib/types/orders.ts index 7a0fae4b..67167bcb 100644 --- a/frontend/src/lib/types/orders.ts +++ b/frontend/src/lib/types/orders.ts @@ -5,7 +5,7 @@ export type Order = export type OrderWithProduct = paths["/orders/{order_id}"]["get"]["responses"][200]["content"]["application/json"] & { - product: ProductInfo; + product?: ProductInfo; }; export type ProductInfo = { @@ -16,7 +16,10 @@ export type ProductInfo = { metadata: Record; }; -export const orderStatuses = [ +export type OrderStatus = + paths["/orders/{order_id}"]["get"]["responses"][200]["content"]["application/json"]["order"]["status"]; + +export const orderStatuses: OrderStatus[] = [ "processing", "in_development", "being_assembled", diff --git a/frontend/src/lib/types/routes.ts b/frontend/src/lib/types/routes.ts index 4bdba5f3..47ae9064 100644 --- a/frontend/src/lib/types/routes.ts +++ b/frontend/src/lib/types/routes.ts @@ -82,6 +82,9 @@ const ROUTES = { // Link robot LINK: route("link"), + // Admin Dashboard + ADMIN: route("admin"), + // Not found NOT_FOUND: route("404"), }; diff --git a/frontend/src/lib/utils/orders.ts b/frontend/src/lib/utils/orders.ts new file mode 100644 index 00000000..5dbec7f7 --- /dev/null +++ b/frontend/src/lib/utils/orders.ts @@ -0,0 +1,6 @@ +import type { OrderWithProduct } from "@/lib/types/orders"; +import { canModifyStatuses } from "@/lib/types/orders"; + +export const canModifyOrder = (order: OrderWithProduct) => { + return canModifyStatuses.includes(order.order.status); +}; diff --git a/store/app/crud/orders.py b/store/app/crud/orders.py index 602aedf4..bbbf3bcd 100644 --- a/store/app/crud/orders.py +++ b/store/app/crud/orders.py @@ -55,6 +55,14 @@ class OrderDataUpdate(TypedDict): shipping_country: NotRequired[str] +class ProcessPreorderData(TypedDict): + stripe_connect_account_id: str + stripe_checkout_session_id: str + final_payment_checkout_session_id: str + status: OrderStatus + updated_at: int + + class OrdersNotFoundError(ItemNotFoundError): """Raised when no orders are found for a user.""" @@ -97,3 +105,26 @@ async def update_order(self, order_id: str, update_data: OrderDataUpdate) -> Ord if not updated_order: raise ItemNotFoundError("Updated order not found") return updated_order + + async def process_preorder(self, order_id: str, update_data: ProcessPreorderData) -> Order: + order = await self.get_order(order_id) + if not order: + raise ItemNotFoundError("Order not found") + + current_data = order.model_dump() + update_dict = dict(update_data) + current_data.update(update_dict) + + try: + Order(**current_data) + except ValidationError as e: + raise ValueError(f"Invalid update data: {str(e)}") + + await self._update_item(order_id, Order, update_dict) + updated_order = await self.get_order(order_id) + if not updated_order: + raise ItemNotFoundError("Updated order not found") + return updated_order + + async def dump_orders(self) -> list[Order]: + return await self._list_items(Order) diff --git a/store/app/routers/orders.py b/store/app/routers/orders.py index 1caaba4d..4c74768d 100644 --- a/store/app/routers/orders.py +++ b/store/app/routers/orders.py @@ -2,16 +2,17 @@ import asyncio import logging +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from store.app.crud.orders import OrderDataUpdate, OrdersNotFoundError from store.app.db import Crud -from store.app.model import Order, User +from store.app.model import Order, OrderStatus, User from store.app.routers import stripe from store.app.security.user import ( - get_session_user_with_read_permission, + get_session_user_with_admin_permission, get_session_user_with_write_permission, ) @@ -36,8 +37,8 @@ class OrderWithProduct(BaseModel): @router.get("/me", response_model=list[OrderWithProduct]) async def get_user_orders( - user: User = Depends(get_session_user_with_read_permission), - crud: Crud = Depends(), + user: Annotated[User, Depends(get_session_user_with_write_permission)], + crud: Annotated[Crud, Depends(Crud.get)], ) -> list[OrderWithProduct]: async def get_product_info(order: Order) -> OrderWithProduct: if not order.stripe_product_id: @@ -81,8 +82,8 @@ async def get_product_info(order: Order) -> OrderWithProduct: @router.get("/{order_id}", response_model=OrderWithProduct) async def get_order( order_id: str, - user: User = Depends(get_session_user_with_read_permission), - crud: Crud = Depends(), + user: Annotated[User, Depends(get_session_user_with_write_permission)], + crud: Annotated[Crud, Depends(Crud.get)], ) -> OrderWithProduct: async with crud: order = await crud.get_order(order_id) @@ -118,8 +119,8 @@ class UpdateOrderAddressRequest(BaseModel): async def update_order_shipping_address( order_id: str, address_update: UpdateOrderAddressRequest, - user: User = Depends(get_session_user_with_write_permission), - crud: Crud = Depends(Crud.get), + user: Annotated[User, Depends(get_session_user_with_write_permission)], + crud: Annotated[Crud, Depends(Crud.get)], ) -> Order: order = await crud.get_order(order_id) if order is None or order.user_id != user.id: @@ -138,3 +139,57 @@ async def update_order_shipping_address( updated_order = await crud.update_order(order_id, update_dict) return updated_order + + +class AdminOrdersResponse(BaseModel): + orders: list[OrderWithProduct] + + +@router.get("/admin/all", response_model=AdminOrdersResponse) +async def get_all_orders( + user: Annotated[User, Depends(get_session_user_with_admin_permission)], + crud: Annotated[Crud, Depends(Crud.get)], +) -> AdminOrdersResponse: + """Get all orders (admin only).""" + orders = await crud.dump_orders() + + orders_with_products = [] + for order in orders: + try: + product = None + if order.stripe_product_id: + product = await stripe.get_product(order.stripe_product_id, crud) + product_info = ProductInfo( + id=product.id, + name=product.name, + description=product.description, + images=product.images, + metadata=product.metadata, + active=product.active, + ) + orders_with_products.append(OrderWithProduct(order=order, product=product_info)) + except Exception as e: + logger.error(f"Error getting product info for order {order.id}: {str(e)}") + orders_with_products.append(OrderWithProduct(order=order, product=None)) + + return AdminOrdersResponse(orders=orders_with_products) + + +class UpdateOrderStatusRequest(BaseModel): + status: OrderStatus + + +@router.put("/admin/status/{order_id}", response_model=Order) +async def update_order_status( + order_id: str, + status_update: UpdateOrderStatusRequest, + user: Annotated[User, Depends(get_session_user_with_admin_permission)], + crud: Annotated[Crud, Depends(Crud.get)], +) -> Order: + order = await crud.get_order(order_id) + if order is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Order not found") + + update_dict = OrderDataUpdate(status=status_update.status) + updated_order = await crud.update_order(order_id, update_dict) + return updated_order diff --git a/store/app/routers/robots.py b/store/app/routers/robots.py index b8eb23dd..62ffdede 100644 --- a/store/app/routers/robots.py +++ b/store/app/routers/robots.py @@ -3,11 +3,9 @@ import asyncio from typing import Self -from annotated_types import MaxLen from boto3.dynamodb.conditions import Key from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, ValidationError -from typing_extensions import Annotated from store.app.crud.base import ItemNotFoundError from store.app.crud.robots import RobotData diff --git a/store/app/routers/stripe.py b/store/app/routers/stripe.py index 985460c8..06bc790c 100644 --- a/store/app/routers/stripe.py +++ b/store/app/routers/stripe.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Request, status from pydantic import BaseModel -from store.app.crud.orders import OrderDataCreate, OrderDataUpdate +from store.app.crud.orders import OrderDataCreate, OrderDataUpdate, ProcessPreorderData from store.app.db import Crud from store.app.model import Listing, Order, User, UserStripeConnect from store.app.security.user import ( @@ -678,10 +678,10 @@ async def create_listing_product( class ProcessPreorderResponse(BaseModel): status: str - checkout_session: dict[str, Any] | None + checkout_session: dict[str, Any] -@router.post("/process-preorder/{order_id}", response_model=ProcessPreorderResponse) +@router.post("/process/preorder/{order_id}", response_model=ProcessPreorderResponse) async def process_preorder( order_id: str, crud: Annotated[Crud, Depends(Crud.get)], @@ -689,7 +689,6 @@ async def process_preorder( ) -> ProcessPreorderResponse: async with crud: try: - # Get the order and verify it's a preorder order = await crud.get_order(order_id) if not order: raise HTTPException(status_code=404, detail="Order not found") @@ -711,7 +710,7 @@ async def process_preorder( # Create a new checkout session for the final payment checkout_params: stripe.checkout.Session.CreateParams = { "mode": "payment", - "payment_method_types": ["card", "affirm"], # Enable both card and Affirm + "payment_method_types": ["card", "affirm"], "success_url": STRIPE_CONNECT_FINAL_PAYMENT_SUCCESS_URL, "cancel_url": STRIPE_CONNECT_FINAL_PAYMENT_CANCEL_URL, "metadata": { @@ -764,16 +763,17 @@ async def process_preorder( # Create checkout session on seller's connect account checkout_session = stripe.checkout.Session.create(**checkout_params) - # Update order with final payment checkout session - order_data: OrderDataUpdate = { - "stripe_connect_account_id": seller.stripe_connect.account_id, - "stripe_checkout_session_id": order.stripe_checkout_session_id, - "final_payment_checkout_session_id": checkout_session.id, - "status": "awaiting_final_payment", - "updated_at": int(time.time()), - } - - await crud.update_order(order_id, order_data) + if checkout_session.id: + # Update order with final payment checkout session + order_data: ProcessPreorderData = { + "stripe_connect_account_id": seller.stripe_connect.account_id, + "stripe_checkout_session_id": order.stripe_checkout_session_id, + "final_payment_checkout_session_id": checkout_session.id, + "status": "awaiting_final_payment", + "updated_at": int(time.time()), + } + + await crud.process_preorder(order_id, order_data) return ProcessPreorderResponse( status="success",