From 3f51ac4c2e2b37cd6ff6166b9fd193baa28f57fa Mon Sep 17 00:00:00 2001 From: nn2-minh <Nguyen12.Minh@live.uwe.ac.uk> Date: Sat, 26 Apr 2025 20:20:44 +0700 Subject: [PATCH] add admin dashboard, update category function, admin use a seperate script to create account if admin account doesnt exist before that. Update run_app.py so that it can run init.py which initialization the script admin. Update relation between admin, owner and user. Update owner so that owner can update category into their product. Update owner dashboard so that it can display the category of the product. update readme for easier guide. --- README.md | 20 + app/backend/database.py | 2 +- app/backend/main.py | 4 +- app/backend/routes/admin.py | 179 +++++ app/backend/routes/category.py | 65 +- app/backend/routes/product.py | 23 +- app/backend/schemas/category.py | 9 +- app/backend/schemas/product.py | 25 +- app/backend/scripts/__init__.py | 2 + app/backend/scripts/admin_init.py | 45 ++ app/frontend/components/admin/__init__.py | 5 + app/frontend/components/admin/category.py | 518 ++++++++++++-- .../components/admin/category_management.py | 570 +++++++++++++++ app/frontend/components/admin/dashboard.py | 223 ++++++ .../components/admin/product_management.py | 667 ++++++++++++++++++ .../components/admin/shop_owner_management.py | 478 +++++++++++++ .../components/admin/user_management.py | 313 ++++++++ .../components/owner/owner_dashboard.py | 58 +- app/frontend/components/owner/owner_orders.py | 22 +- .../components/owner/owner_products.py | 54 +- .../components/product/create_product.py | 68 ++ .../components/product/edit_product.py | 112 ++- .../components/product/view_product.py | 30 +- app/frontend/main.py | 77 +- app/init.py | 21 + run_app.py | 6 + 26 files changed, 3469 insertions(+), 127 deletions(-) create mode 100644 app/backend/routes/admin.py create mode 100644 app/backend/scripts/__init__.py create mode 100644 app/backend/scripts/admin_init.py create mode 100644 app/frontend/components/admin/__init__.py create mode 100644 app/frontend/components/admin/category_management.py create mode 100644 app/frontend/components/admin/dashboard.py create mode 100644 app/frontend/components/admin/product_management.py create mode 100644 app/frontend/components/admin/shop_owner_management.py create mode 100644 app/frontend/components/admin/user_management.py create mode 100644 app/init.py diff --git a/README.md b/README.md index e6f391a..d45c966 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,23 @@ create `.env` file based on `.env.example` in the main directory - open terminal and use \ `uvicorn app.backend.main:app --reload` - open `127.0.0.1:8000/docs` on your browser + +# 5: Admin Dashboard + +The application includes an admin dashboard with the following features: + +- User Management: View and delete regular user accounts +- Shop Owner Management: View and delete shop owner accounts, including viewing their shops +- Category Management: Create, update, and delete product categories + +## Default Admin Credentials + +- Username/Email: admin@example.com +- Password: admin + +The default admin user is automatically created on first run of the application. + +To run the full application (backend + frontend): +``` +python run_app.py +``` diff --git a/app/backend/database.py b/app/backend/database.py index 340a31f..2f7bdca 100644 --- a/app/backend/database.py +++ b/app/backend/database.py @@ -1,5 +1,5 @@ from sqlmodel import SQLModel, Session, create_engine -from app.core.config import settings +from core.config import settings from backend.dummy_data import insert_dummy_data engine = create_engine(settings.database_url, echo=settings.debug) diff --git a/app/backend/main.py b/app/backend/main.py index 700b51b..a9f862a 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -5,7 +5,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from backend.routes import auth, shop, product, category, search, order, payment, cart +from backend.routes import auth, shop, product, category, search, order, payment, cart, admin from backend.database import init_db from core.config import settings @@ -32,6 +32,8 @@ app.include_router(order.router, prefix="/order", tags=["order"]) app.include_router(order.router, prefix="/orders", tags=["orders"]) # Use dedicated cart router app.include_router(cart.router, prefix="/cart", tags=["cart"]) +# Admin routes +app.include_router(admin.router, prefix="/admin", tags=["admin"]) @app.get("/") diff --git a/app/backend/routes/admin.py b/app/backend/routes/admin.py new file mode 100644 index 0000000..3ba82d2 --- /dev/null +++ b/app/backend/routes/admin.py @@ -0,0 +1,179 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select +from backend.models.models import User, Shop, Category +from backend.database import get_session +from backend.routes.auth import get_current_user +from typing import List + +router = APIRouter() + + +def verify_admin(current_user: User): + """Verify that the current user is an admin""" + if current_user.role != "admin": + raise HTTPException( + status_code=403, detail="Unauthorized. Admin access required." + ) + + +@router.get("/users") +def get_all_users( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get all users""" + verify_admin(current_user) + + users = session.exec(select(User)).all() + return users + + +@router.get("/users/{user_id}") +def get_user( + user_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get a specific user""" + verify_admin(current_user) + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.delete("/users/{user_id}") +def delete_user( + user_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Delete a user (ban)""" + verify_admin(current_user) + + # Prevent admin from deleting themselves + if user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete own account") + + # Prevent admin from deleting other admins + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.role == "admin": + raise HTTPException(status_code=400, detail="Cannot delete admin accounts") + + # Delete the user + session.delete(user) + session.commit() + + return {"message": "User deleted successfully"} + + +@router.get("/owners") +def get_shop_owners( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get all shop owners""" + verify_admin(current_user) + + # Get users with role="shop_owner" + shop_owners = session.exec(select(User).where(User.role == "shop_owner")).all() + + # For each shop owner, get their shops + for owner in shop_owners: + owner.shops = session.exec(select(Shop).where(Shop.owner_id == owner.id)).all() + + return shop_owners + + +@router.get("/owners/{owner_id}") +def get_shop_owner( + owner_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get a specific shop owner""" + verify_admin(current_user) + + owner = session.get(User, owner_id) + if not owner: + raise HTTPException(status_code=404, detail="Shop owner not found") + + if owner.role != "shop_owner": + raise HTTPException(status_code=400, detail="User is not a shop owner") + + # Get the owner's shops + owner.shops = session.exec(select(Shop).where(Shop.owner_id == owner.id)).all() + + return owner + + +@router.get("/owners/{owner_id}/shops") +def get_owner_shops( + owner_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get shops owned by a specific shop owner""" + verify_admin(current_user) + + # Check if the owner exists + owner = session.get(User, owner_id) + if not owner: + raise HTTPException(status_code=404, detail="Shop owner not found") + + if owner.role != "shop_owner": + raise HTTPException(status_code=400, detail="User is not a shop owner") + + # Get the owner's shops + shops = session.exec(select(Shop).where(Shop.owner_id == owner_id)).all() + + return shops + + +@router.delete("/owners/{owner_id}") +def delete_shop_owner( + owner_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Delete a shop owner (ban)""" + verify_admin(current_user) + + # Prevent admin from deleting themselves + if owner_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete own account") + + # Verify the owner exists + owner = session.get(User, owner_id) + if not owner: + raise HTTPException(status_code=404, detail="Shop owner not found") + + if owner.role != "shop_owner": + raise HTTPException(status_code=400, detail="User is not a shop owner") + + # Delete all shops owned by this user first + shops = session.exec(select(Shop).where(Shop.owner_id == owner_id)).all() + for shop in shops: + session.delete(shop) + + # Delete the owner + session.delete(owner) + session.commit() + + return {"message": "Shop owner deleted successfully"} + + +@router.get("/categories") +def get_all_categories( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + """Get all categories for admin panel""" + verify_admin(current_user) + + categories = session.exec(select(Category)).all() + return categories \ No newline at end of file diff --git a/app/backend/routes/category.py b/app/backend/routes/category.py index 5aa3a5a..16f48b3 100644 --- a/app/backend/routes/category.py +++ b/app/backend/routes/category.py @@ -1,9 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException, Form -from sqlmodel import Session -from backend.models.models import Category, User +from sqlmodel import Session, select +from backend.models.models import Category, User, Product from backend.schemas.category import CategoryRead from backend.database import get_session from backend.routes.auth import get_current_user +from typing import List +from sqlalchemy.exc import IntegrityError router = APIRouter() @@ -22,12 +24,25 @@ def create_category( current_user: User = Depends(get_current_user), ): verify_admin(current_user) + + # Check if category with this name already exists + existing_category = session.exec(select(Category).where(Category.name == name)).first() + if existing_category: + raise HTTPException( + status_code=400, detail=f"Category with name '{name}' already exists" + ) category = Category(name=name) session.add(category) - session.commit() - session.refresh(category) - return category + try: + session.commit() + session.refresh(category) + return category + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=400, detail=f"Category with name '{name}' already exists" + ) @router.get("/get/{category_id}", response_model=CategoryRead) @@ -51,12 +66,26 @@ def update_category( raise HTTPException(status_code=404, detail="Category not found") if name: + # Check if another category already has this name + existing_category = session.exec( + select(Category).where(Category.name == name, Category.id != category_id) + ).first() + if existing_category: + raise HTTPException( + status_code=400, detail=f"Category with name '{name}' already exists" + ) category.name = name session.add(category) - session.commit() - session.refresh(category) - return category + try: + session.commit() + session.refresh(category) + return category + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=400, detail="Failed to update category. Name may be a duplicate." + ) @router.delete("/delete/{category_id}") @@ -70,6 +99,26 @@ def delete_category( category = session.get(Category, category_id) if not category: raise HTTPException(status_code=404, detail="Category not found") + + # Check if any products are using this category + products_using_category = session.exec( + select(Product).where(Product.category_id == category_id) + ).all() + + if products_using_category: + product_count = len(products_using_category) + raise HTTPException( + status_code=400, + detail=f"Cannot delete category: {product_count} products are using this category. Please reassign or delete those products first." + ) + session.delete(category) session.commit() return {"message": "Category deleted successfully"} + + +@router.get("") +def get_all_categories(session: Session = Depends(get_session)): + """Get all categories""" + categories = session.exec(select(Category)).all() + return categories diff --git a/app/backend/routes/product.py b/app/backend/routes/product.py index 622905b..4c30adb 100644 --- a/app/backend/routes/product.py +++ b/app/backend/routes/product.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlmodel import Session, func +from sqlmodel import Session, func, select +from sqlalchemy.orm import joinedload from datetime import datetime -from backend.models.models import Product, ProductImage, User, Shop, OrderItem, CartItem +from backend.models.models import Product, ProductImage, User, Shop, OrderItem, CartItem, Category from backend.schemas.product import ProductRead, ProductUpdate from backend.database import get_session from backend.routes.auth import get_current_user @@ -43,7 +44,7 @@ def create_product( ) session.add(product) session.commit() - session.refresh(product) + session.refresh(product, ["category"]) # Directory: static/shop_{shop.name}/product_{product.name}/ shop_dir = os.path.join(settings.static_dir, f"shop_{shop.name}") @@ -89,6 +90,7 @@ def read_all_products(order: str = "desc", session: Session = Depends(get_sessio ) products = ( session.query(Product) + .options(joinedload(Product.category)) # Explicitly load the category relationship .outerjoin(OrderItem, Product.id == OrderItem.product_id) .group_by(Product.id) .order_by(order_by) @@ -112,6 +114,7 @@ def get_shop_products( if shop.owner_id != current_user.id: products = ( session.query(Product) + .options(joinedload(Product.category)) # Explicitly load the category relationship .filter( Product.shop_id == shop_id, Product.stock > 0, # Only available products @@ -120,7 +123,12 @@ def get_shop_products( ) else: # Shop owner can see all their products - products = session.query(Product).filter(Product.shop_id == shop_id).all() + products = ( + session.query(Product) + .options(joinedload(Product.category)) # Explicitly load the category relationship + .filter(Product.shop_id == shop_id) + .all() + ) return products @@ -130,6 +138,9 @@ def read_product(product_id: int, session: Session = Depends(get_session)): product = session.get(Product, product_id) if not product: raise HTTPException(status_code=404, detail="Product not found") + + # Make sure category is loaded + session.refresh(product, ["category"]) return product @@ -140,7 +151,7 @@ def update_product( description: str = Form(...), price: float = Form(...), stock: int = Form(...), - category_id: int = Form(...), + category_id: int = Form(None), images: list[UploadFile] = File(...), session: Session = Depends(get_session), current_user: User = Depends(get_current_user), @@ -159,7 +170,7 @@ def update_product( session.add(product) session.commit() - session.refresh(product) + session.refresh(product, ["category"]) shop = session.get(Shop, product.shop_id) shop_dir = os.path.join(settings.static_dir, f"shop_{shop.name}") diff --git a/app/backend/schemas/category.py b/app/backend/schemas/category.py index 79fb6d0..fbc1e06 100644 --- a/app/backend/schemas/category.py +++ b/app/backend/schemas/category.py @@ -1,9 +1,13 @@ from sqlmodel import SQLModel +from pydantic import BaseModel from typing import Optional -class CategoryBase(SQLModel): +class CategoryBase(BaseModel): name: str + + class Config: + from_attributes = True class CategoryCreate(CategoryBase): @@ -12,6 +16,9 @@ class CategoryCreate(CategoryBase): class CategoryRead(CategoryBase): id: int + + class Config: + from_attributes = True class CategoryUpdate(SQLModel): diff --git a/app/backend/schemas/product.py b/app/backend/schemas/product.py index cd3e466..ac8d818 100644 --- a/app/backend/schemas/product.py +++ b/app/backend/schemas/product.py @@ -1,8 +1,20 @@ from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, ForwardRef from datetime import datetime +class CategoryBase(BaseModel): + id: int + name: str + + class Config: + from_attributes = True + + +# Forward references for circular relationships +ProductImageRead = ForwardRef('ProductImageRead') + + class ProductBase(BaseModel): shop_id: int category_id: Optional[int] = None @@ -19,10 +31,11 @@ class ProductCreate(ProductBase): class ProductRead(ProductBase): id: int created_at: datetime - images: List["ProductImageRead"] = [] + images: List[ProductImageRead] = [] + category: Optional[CategoryBase] = None class Config: - orm_mode = True + from_attributes = True class ProductUpdate(BaseModel): @@ -47,4 +60,8 @@ class ProductImageRead(ProductImageBase): id: int class Config: - orm_mode = True + from_attributes = True + + +# Resolve forward references +ProductRead.update_forward_refs() diff --git a/app/backend/scripts/__init__.py b/app/backend/scripts/__init__.py new file mode 100644 index 0000000..d089d7f --- /dev/null +++ b/app/backend/scripts/__init__.py @@ -0,0 +1,2 @@ +# Scripts for backend initialization and maintenance +from .admin_init import init_admin \ No newline at end of file diff --git a/app/backend/scripts/admin_init.py b/app/backend/scripts/admin_init.py new file mode 100644 index 0000000..70ac94a --- /dev/null +++ b/app/backend/scripts/admin_init.py @@ -0,0 +1,45 @@ +import sys +import os + +# Add the parent directory to the path to allow importing from the backend +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from backend.database import get_session, engine +from backend.models.models import SQLModel, User +from backend.utils.hashing import hash_password +from sqlmodel import select, Session + + +def init_admin(): + """Initialize a default admin user if no admin user exists""" + print("Initializing admin user...") + + # Create all tables if they don't exist + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + # Check if admin user already exists + admin = session.exec(select(User).where(User.role == "admin")).first() + + if admin: + print("Admin user already exists.") + return + + print("Creating default admin user...") + # Create default admin user with username and password both "admin" + hashed_password = hash_password("admin") + admin_user = User( + username="admin", + email="admin@example.com", + password=hashed_password, + phone_number="1234567890", + role="admin" + ) + + session.add(admin_user) + session.commit() + print("Default admin user created successfully!") + + +if __name__ == "__main__": + init_admin() \ No newline at end of file diff --git a/app/frontend/components/admin/__init__.py b/app/frontend/components/admin/__init__.py new file mode 100644 index 0000000..1f6bf6c --- /dev/null +++ b/app/frontend/components/admin/__init__.py @@ -0,0 +1,5 @@ +# Admin dashboard components +from .dashboard import admin_dashboard_frame +from .user_management import admin_user_management_frame +from .shop_owner_management import admin_shop_owner_management_frame +from .category import category_frame \ No newline at end of file diff --git a/app/frontend/components/admin/category.py b/app/frontend/components/admin/category.py index 29c239e..270f94f 100644 --- a/app/frontend/components/admin/category.py +++ b/app/frontend/components/admin/category.py @@ -1,117 +1,523 @@ import customtkinter as ctk +from CTkMessagebox import CTkMessagebox import requests -from tkinter import messagebox +from tkinter import ttk +from datetime import datetime def category_frame(parent, switch_func, API_URL, access_token): frame = ctk.CTkFrame(parent) - + + # Update token function for consistency with other frames + def update_token(new_token): + nonlocal access_token + headers["Authorization"] = f"Bearer {new_token}" + access_token = new_token + + frame.update_token = update_token + + # Main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#9c27b0") + header_frame.pack(fill="x", pady=(0, 20)) + + # Title and description + title_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + title_frame.pack(padx=20, pady=15) + ctk.CTkLabel( - frame, text="Category Management", font=("Helvetica", 18, "bold") - ).pack(pady=10) - - ctk.CTkLabel(frame, text="Category Name:").pack(pady=5) - entry_name = ctk.CTkEntry(frame) - entry_name.pack(pady=5) - - ctk.CTkLabel(frame, text="Category ID (for update/delete):").pack(pady=5) - entry_id = ctk.CTkEntry(frame) - entry_id.pack(pady=5) - + title_frame, + text="Category Management", + font=("Helvetica", 24, "bold"), + text_color="#ffffff" + ).pack(anchor="w") + + ctk.CTkLabel( + title_frame, + text="Create, view, update and delete product categories", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack(anchor="w") + + # Two column layout + content_frame = ctk.CTkFrame(main_container) + content_frame.pack(fill="both", expand=True) + + # Left column - category form + form_frame = ctk.CTkFrame(content_frame, fg_color="transparent") + form_frame.pack(side="left", fill="both", expand=True, padx=20, pady=20) + + # Form section title + ctk.CTkLabel( + form_frame, + text="Category Details", + font=("Helvetica", 16, "bold") + ).pack(anchor="w", pady=(0, 15)) + + # Form fields with better spacing and styling + input_frame = ctk.CTkFrame(form_frame, fg_color="#f5f5f5", corner_radius=10) + input_frame.pack(fill="x", pady=10) + + # Category name input + name_frame = ctk.CTkFrame(input_frame, fg_color="transparent") + name_frame.pack(fill="x", padx=20, pady=15) + + ctk.CTkLabel( + name_frame, + text="Category Name:", + font=("Helvetica", 12, "bold"), + text_color="#333333" + ).pack(side="left", padx=(0, 10)) + + entry_name = ctk.CTkEntry( + name_frame, + width=250, + height=35, + placeholder_text="Enter category name" + ) + entry_name.pack(side="left", fill="x", expand=True) + + # Category ID input (for update/delete) + id_frame = ctk.CTkFrame(input_frame, fg_color="transparent") + id_frame.pack(fill="x", padx=20, pady=15) + + ctk.CTkLabel( + id_frame, + text="Category ID:", + font=("Helvetica", 12, "bold"), + text_color="#333333" + ).pack(side="left", padx=(0, 10)) + + entry_id = ctk.CTkEntry( + id_frame, + width=150, + height=35, + placeholder_text="For update/delete" + ) + entry_id.pack(side="left") + + # Buttons frame + buttons_frame = ctk.CTkFrame(form_frame, fg_color="transparent") + buttons_frame.pack(fill="x", pady=15) + + # Row 1 of buttons + buttons_row1 = ctk.CTkFrame(buttons_frame, fg_color="transparent") + buttons_row1.pack(fill="x", pady=5) + + create_button = ctk.CTkButton( + buttons_row1, + text="Create Category", + font=("Helvetica", 12, "bold"), + height=40, + fg_color="#4caf50", + hover_color="#388e3c", + corner_radius=8 + ) + create_button.pack(side="left", padx=(0, 10), fill="x", expand=True) + + update_button = ctk.CTkButton( + buttons_row1, + text="Update Category", + font=("Helvetica", 12, "bold"), + height=40, + fg_color="#ff9800", + hover_color="#f57c00", + corner_radius=8 + ) + update_button.pack(side="left", fill="x", expand=True) + + # Row 2 of buttons + buttons_row2 = ctk.CTkFrame(buttons_frame, fg_color="transparent") + buttons_row2.pack(fill="x", pady=5) + + list_button = ctk.CTkButton( + buttons_row2, + text="List Categories", + font=("Helvetica", 12, "bold"), + height=40, + fg_color="#3a7ebf", + hover_color="#2a6da9", + corner_radius=8 + ) + list_button.pack(side="left", padx=(0, 10), fill="x", expand=True) + + delete_button = ctk.CTkButton( + buttons_row2, + text="Delete Category", + font=("Helvetica", 12, "bold"), + height=40, + fg_color="#f44336", + hover_color="#d32f2f", + corner_radius=8 + ) + delete_button.pack(side="left", fill="x", expand=True) + + # Right column - category list table + table_frame = ctk.CTkFrame(content_frame) + table_frame.pack(side="right", fill="both", expand=True, padx=20, pady=20) + + # Table title + ctk.CTkLabel( + table_frame, + text="Category List", + font=("Helvetica", 16, "bold") + ).pack(anchor="w", padx=15, pady=15) + + # Create TreeView for categories + tree_container = ctk.CTkFrame(table_frame) + tree_container.pack(fill="both", expand=True, padx=15, pady=(0, 15)) + + # Configure Treeview style + style = ttk.Style() + style.theme_use("clam") # Use clam theme as base + + # Configure the Treeview colors to match app theme + style.configure( + "Treeview", + background="#2b2b2b", + foreground="#ffffff", + fieldbackground="#2b2b2b", + borderwidth=0, + rowheight=35 + ) + + # Configure the headings + style.configure( + "Treeview.Heading", + background="#9c27b0", + foreground="#ffffff", + borderwidth=0, + font=("Helvetica", 12, "bold") + ) + + # Selection color + style.map("Treeview", background=[("selected", "#9c27b0")]) + + # Create scrollbar + scrollbar = ttk.Scrollbar(tree_container) + scrollbar.pack(side="right", fill="y") + + # Create the TreeView + columns = ("id", "name", "total_products") + category_tree = ttk.Treeview( + tree_container, + columns=columns, + show="headings", + yscrollcommand=scrollbar.set + ) + + # Configure scrollbar + scrollbar.config(command=category_tree.yview) + + # Define headings + category_tree.heading("id", text="ID") + category_tree.heading("name", text="Category Name") + category_tree.heading("total_products", text="Products") + + # Define column widths + category_tree.column("id", width=60, anchor="center") + category_tree.column("name", width=200) + category_tree.column("total_products", width=80, anchor="center") + + # Add tag configurations + category_tree.tag_configure("odd", background="#252525") + category_tree.tag_configure("even", background="#2d2d2d") + + category_tree.pack(fill="both", expand=True) + + # Footer with back button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(15, 0)) + + back_button = ctk.CTkButton( + footer_frame, + text="Back to Admin Dashboard", + command=lambda: switch_func("admin_dashboard"), + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + back_button.pack(side="left") + + # Set up auth headers headers = {"Authorization": f"Bearer {access_token}"} if access_token else {} - + + # Enhanced category functions with table updates + def fetch_categories(): + """Fetch and display categories in the treeview""" + try: + response = requests.get(f"{API_URL}/admin/categories", headers=headers) + if response.status_code == 200: + # Clear existing items + for item in category_tree.get_children(): + category_tree.delete(item) + + categories = response.json() + + if categories: + for i, cat in enumerate(categories): + # Get product count (placeholder - would need backend support) + product_count = len(cat.get("products", [])) + + # Add with alternating row colors + tag = "even" if i % 2 == 0 else "odd" + category_tree.insert( + "", "end", + values=(cat["id"], cat["name"], product_count), + tags=(tag,) + ) + else: + # Empty message could be displayed here + pass + + return categories + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel" + ) + return [] + else: + CTkMessagebox( + title="Error", + message="Failed to fetch categories", + icon="cancel" + ) + return [] + except requests.exceptions.RequestException as e: + CTkMessagebox( + title="Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + return [] + def create_category(): name = entry_name.get().strip() if not name: - messagebox.showwarning("Input Error", "Category name is required!") + CTkMessagebox( + title="Input Error", + message="Category name is required!", + icon="warning" + ) return try: response = requests.post( - f"{API_URL}/category", data={"name": name}, headers=headers + f"{API_URL}/category/create", data={"name": name}, headers=headers ) if response.status_code == 200: - messagebox.showinfo( - "Success", f"Category '{name}' created successfully!" + CTkMessagebox( + title="Success", + message=f"Category '{name}' created successfully!", + icon="check" ) entry_name.delete(0, "end") + fetch_categories() # Refresh the list + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel" + ) else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to create category") + error_detail = "Failed to create category" + try: + error_data = response.json() + if "detail" in error_data: + error_detail = error_data["detail"] + except: + pass + CTkMessagebox( + title="Error", + message=error_detail, + icon="cancel" ) except requests.exceptions.RequestException as e: - messagebox.showerror("Error", f"Failed to connect to server: {e}") + CTkMessagebox( + title="Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) - def list_categories(): - try: - response = requests.get(f"{API_URL}/category", headers=headers) - if response.status_code == 200: - categories = response.json() - if categories: - category_list = "\n".join( - [f"{cat['id']}: {cat['name']}" for cat in categories] - ) - messagebox.showinfo("Categories", category_list) - else: - messagebox.showinfo("Categories", "No categories available.") - else: - messagebox.showerror("Error", "Failed to fetch categories") - except requests.exceptions.RequestException as e: - messagebox.showerror("Error", f"Failed to connect to server: {e}") + def list_categories_dialog(): + """Show categories in a dialog and also update the treeview""" + categories = fetch_categories() + + if categories: + category_list = "\n".join( + [f"{cat['id']}: {cat['name']}" for cat in categories] + ) + CTkMessagebox( + title="Categories", + message=category_list, + icon="info" + ) + else: + CTkMessagebox( + title="Categories", + message="No categories available.", + icon="info" + ) def update_category(): category_id = entry_id.get().strip() name = entry_name.get().strip() if not category_id.isdigit(): - messagebox.showwarning("Input Error", "Enter a valid Category ID!") + CTkMessagebox( + title="Input Error", + message="Enter a valid Category ID!", + icon="warning" + ) return if not name: - messagebox.showwarning( - "Input Error", "Category name is required for update!" + CTkMessagebox( + title="Input Error", + message="Category name is required for update!", + icon="warning" ) return try: response = requests.put( - f"{API_URL}/category/{category_id}", + f"{API_URL}/category/put/{category_id}", data={"name": name}, headers=headers, ) if response.status_code == 200: - messagebox.showinfo("Success", "Category updated successfully!") + CTkMessagebox( + title="Success", + message="Category updated successfully!", + icon="check" + ) entry_id.delete(0, "end") entry_name.delete(0, "end") + fetch_categories() # Refresh the list + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel" + ) else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to update category") + error_detail = "Failed to update category" + try: + error_data = response.json() + if "detail" in error_data: + error_detail = error_data["detail"] + except: + pass + CTkMessagebox( + title="Error", + message=error_detail, + icon="cancel" ) except requests.exceptions.RequestException as e: - messagebox.showerror("Error", f"Failed to connect to server: {e}") + CTkMessagebox( + title="Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) def delete_category(): category_id = entry_id.get().strip() if not category_id.isdigit(): - messagebox.showwarning("Input Error", "Enter a valid Category ID!") + CTkMessagebox( + title="Input Error", + message="Enter a valid Category ID!", + icon="warning" + ) return try: response = requests.delete( - f"{API_URL}/category/{category_id}", headers=headers + f"{API_URL}/category/delete/{category_id}", headers=headers ) if response.status_code == 200: - messagebox.showinfo("Success", "Category deleted successfully!") + CTkMessagebox( + title="Success", + message="Category deleted successfully!", + icon="check" + ) entry_id.delete(0, "end") + fetch_categories() # Refresh the list + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel" + ) + elif response.status_code == 400: + try: + error_data = response.json() + error_message = error_data.get("detail", "Failed to delete category") + + # Check if the error is about products using the category + if "products are using this category" in error_message: + CTkMessagebox( + title="Cannot Delete Category", + message=error_message, + icon="warning" + ) + else: + CTkMessagebox( + title="Error", + message=error_message, + icon="cancel" + ) + except: + CTkMessagebox( + title="Error", + message="Failed to delete category. Please try again.", + icon="cancel" + ) else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to delete category") + error_detail = "Failed to delete category" + try: + error_data = response.json() + if "detail" in error_data: + error_detail = error_data["detail"] + except: + pass + CTkMessagebox( + title="Error", + message=error_detail, + icon="cancel" ) except requests.exceptions.RequestException as e: - messagebox.showerror("Error", f"Failed to connect to server: {e}") + CTkMessagebox( + title="Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + + # Helper function to fill form from selected category + def on_category_select(event): + selected_item = category_tree.selection() + if selected_item: + values = category_tree.item(selected_item[0])["values"] + entry_id.delete(0, "end") + entry_id.insert(0, values[0]) + + entry_name.delete(0, "end") + entry_name.insert(0, values[1]) + + # Bind the selection event + category_tree.bind("<<TreeviewSelect>>", on_category_select) - ctk.CTkButton(frame, text="Create Category", command=create_category).pack(pady=5) - ctk.CTkButton(frame, text="List Categories", command=list_categories).pack(pady=5) - ctk.CTkButton(frame, text="Update Category", command=update_category).pack(pady=5) - ctk.CTkButton(frame, text="Delete Category", command=delete_category).pack(pady=5) - ctk.CTkButton(frame, text="Back", command=lambda: switch_func("")).pack(pady=5) + # Connect buttons to functions + create_button.configure(command=create_category) + list_button.configure(command=list_categories_dialog) + update_button.configure(command=update_category) + delete_button.configure(command=delete_category) + + # Initial fetch of categories + frame.after(100, fetch_categories) return frame diff --git a/app/frontend/components/admin/category_management.py b/app/frontend/components/admin/category_management.py new file mode 100644 index 0000000..0bfcf56 --- /dev/null +++ b/app/frontend/components/admin/category_management.py @@ -0,0 +1,570 @@ +import customtkinter as ctk +from tkinter import ttk +from CTkMessagebox import CTkMessagebox +import requests +from datetime import datetime + +def admin_category_management_frame(parent, switch_func, API_URL, access_token): + """ + Admin dashboard component for managing product categories + """ + frame = ctk.CTkFrame(parent) + + # Store the token for later use + def update_token(new_token): + nonlocal access_token + access_token = new_token + + frame.update_token = update_token + + # Create main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#2e8b57") + header_frame.pack(fill="x", pady=(0, 20)) + + # Title and description + title_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + title_frame.pack(padx=20, pady=15) + + ctk.CTkLabel( + title_frame, + text="Category Management", + font=("Helvetica", 24, "bold"), + text_color="#ffffff" + ).pack(anchor="w") + + ctk.CTkLabel( + title_frame, + text="Manage marketplace product categories", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack(anchor="w") + + # Control panel + control_panel = ctk.CTkFrame(main_container, corner_radius=10, height=80) + control_panel.pack(fill="x", pady=(0, 15)) + + # Left side - search + search_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + search_frame.pack(side="left", padx=20, pady=15, fill="y") + + ctk.CTkLabel(search_frame, text="Search:", font=("Helvetica", 12, "bold")).pack(side="left", padx=(0, 10)) + + search_entry = ctk.CTkEntry(search_frame, width=250, height=35, placeholder_text="Search categories...") + search_entry.pack(side="left", padx=(0, 10)) + + search_button = ctk.CTkButton( + search_frame, + text="Search", + font=("Helvetica", 12, "bold"), + height=35, + corner_radius=8, + fg_color="#2e8b57", + hover_color="#1f6e42" + ) + search_button.pack(side="left") + + # Right side - actions + actions_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + actions_frame.pack(side="right", padx=20, pady=15, fill="y") + + add_button = ctk.CTkButton( + actions_frame, + text="Add Category", + font=("Helvetica", 12, "bold"), + height=35, + width=120, + corner_radius=8, + fg_color="#4caf50", + hover_color="#388e3c" + ) + add_button.pack(side="left", padx=(0, 10)) + + edit_button = ctk.CTkButton( + actions_frame, + text="Edit", + font=("Helvetica", 12, "bold"), + height=35, + width=80, + corner_radius=8, + fg_color="#3a7ebf", + hover_color="#2a6da9" + ) + edit_button.pack(side="left", padx=(0, 10)) + + delete_button = ctk.CTkButton( + actions_frame, + text="Delete", + font=("Helvetica", 12, "bold"), + height=35, + width=80, + corner_radius=8, + fg_color="#f44336", + hover_color="#d32f2f" + ) + delete_button.pack(side="left") + + # Data table frame + table_frame = ctk.CTkFrame(main_container, corner_radius=10) + table_frame.pack(fill="both", expand=True) + + # Configure Treeview style + style = ttk.Style() + style.theme_use("clam") # Use clam theme as base + + # Configure the Treeview colors to match app theme + style.configure( + "Treeview", + background="#2b2b2b", + foreground="#ffffff", + fieldbackground="#2b2b2b", + borderwidth=0, + rowheight=40 + ) + + # Configure the headings + style.configure( + "Treeview.Heading", + background="#2e8b57", + foreground="#ffffff", + borderwidth=0, + font=("Helvetica", 12, "bold") + ) + + # Selection color + style.map("Treeview", background=[("selected", "#2e8b57")]) + + # Create scrollbar + scrollbar = ttk.Scrollbar(table_frame) + scrollbar.pack(side="right", fill="y") + + # Create Treeview + columns = ("id", "name", "description", "product_count", "created_at", "parent_category") + category_tree = ttk.Treeview( + table_frame, + columns=columns, + show="headings", + yscrollcommand=scrollbar.set + ) + + # Configure scrollbar + scrollbar.config(command=category_tree.yview) + + # Define column headings + category_tree.heading("id", text="ID") + category_tree.heading("name", text="Category Name") + category_tree.heading("description", text="Description") + category_tree.heading("product_count", text="Products") + category_tree.heading("created_at", text="Created At") + category_tree.heading("parent_category", text="Parent Category") + + # Define column widths and alignment + category_tree.column("id", width=60, anchor="center") + category_tree.column("name", width=150) + category_tree.column("description", width=200) + category_tree.column("product_count", width=80, anchor="center") + category_tree.column("created_at", width=150, anchor="center") + category_tree.column("parent_category", width=150) + + category_tree.pack(fill="both", expand=True, padx=5, pady=5) + + # Footer with back button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(15, 0)) + + back_button = ctk.CTkButton( + footer_frame, + text="Back to Admin Dashboard", + command=lambda: switch_func("admin_dashboard"), + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + back_button.pack(side="left") + + # Function to fetch categories + def fetch_categories(search_term=None): + """Fetch categories from backend""" + headers = {"Authorization": f"Bearer {access_token}"} + + params = {} + if search_term: + params["search"] = search_term + + try: + response = requests.get(f"{API_URL}/admin/categories", headers=headers, params=params) + + if response.status_code == 200: + # Clear current items + for item in category_tree.get_children(): + category_tree.delete(item) + + categories = response.json() + + for category in categories: + # Format date + created_at = "N/A" + if "created_at" in category: + try: + dt = datetime.fromisoformat(category["created_at"].replace("Z", "+00:00")) + created_at = dt.strftime("%Y-%m-%d %H:%M") + except: + pass + + # Handle parent category + parent_category = category.get("parent_name", "None") + + # Insert into tree + category_tree.insert( + "", "end", + values=( + category["id"], + category.get("name", "Unnamed Category"), + category.get("description", ""), + category.get("product_count", 0), + created_at, + parent_category + ) + ) + else: + CTkMessagebox( + title="Error", + message="Failed to fetch categories", + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Error", + message=f"An error occurred: {str(e)}", + icon="cancel" + ) + + # Function to handle search + def on_search(): + search_term = search_entry.get() if search_entry.get() else None + fetch_categories(search_term) + + # Function to delete a category + def delete_category(): + selected_item = category_tree.selection() + if not selected_item: + CTkMessagebox( + title="Warning", + message="Please select a category to delete", + icon="warning" + ) + return + + # Get selected category details + category_id = category_tree.item(selected_item[0])['values'][0] + category_name = category_tree.item(selected_item[0])['values'][1] + + # Confirm deletion + confirm = CTkMessagebox( + title="Confirm Deletion", + message=f"Are you sure you want to delete category '{category_name}'?\nThis action cannot be undone.", + icon="question", + option_1="Cancel", + option_2="Delete" + ) + + if confirm.get() != "Delete": + return + + try: + response = requests.delete( + f"{API_URL}/category/delete/{category_id}", + headers={"Authorization": f"Bearer {access_token}"} + ) + + if response.status_code == 200: + CTkMessagebox( + title="Success", + message=f"Category '{category_name}' deleted successfully!", + icon="check" + ) + fetch_categories() + else: + try: + error_data = response.json() + error_message = error_data.get("detail", "Failed to delete category. Please try again.") + + # Check if the error is about products using the category + if "products are using this category" in error_message: + CTkMessagebox( + title="Cannot Delete Category", + message=error_message, + icon="warning" + ) + else: + CTkMessagebox( + title="Error", + message=error_message, + icon="cancel" + ) + except: + CTkMessagebox( + title="Error", + message="Failed to delete category. Please try again.", + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Error", + message=f"An error occurred: {str(e)}", + icon="cancel" + ) + + # Function to add a new category + def add_category(): + show_category_dialog("Add Category", None) + + # Function to edit a category + def edit_category(): + selected_items = category_tree.selection() + if not selected_items: + CTkMessagebox( + title="Warning", + message="Please select a category to edit", + icon="warning" + ) + return + + category_id = category_tree.item(selected_items[0])["values"][0] + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.get( + f"{API_URL}/admin/categories/{category_id}", + headers=headers + ) + + if response.status_code == 200: + category = response.json() + show_category_dialog("Edit Category", category) + else: + CTkMessagebox( + title="Error", + message="Failed to fetch category details", + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Error", + message=f"An error occurred: {str(e)}", + icon="cancel" + ) + + def show_category_dialog(title, category=None): + """Show dialog for adding/editing a category""" + dialog = ctk.CTkToplevel(frame) + dialog.title(title) + dialog.geometry("500x400") + dialog.resizable(False, False) + dialog.transient(frame) + dialog.grab_set() + + # Form container + form_frame = ctk.CTkFrame(dialog, fg_color="transparent") + form_frame.pack(padx=20, pady=20, fill="both", expand=True) + + # Category name + name_label = ctk.CTkLabel( + form_frame, + text="Category Name:", + font=("Helvetica", 12, "bold") + ) + name_label.pack(anchor="w", pady=(0, 5)) + + name_entry = ctk.CTkEntry(form_frame, width=460, height=35) + name_entry.pack(fill="x", pady=(0, 15)) + + # Description + desc_label = ctk.CTkLabel( + form_frame, + text="Description:", + font=("Helvetica", 12, "bold") + ) + desc_label.pack(anchor="w", pady=(0, 5)) + + desc_entry = ctk.CTkTextbox(form_frame, width=460, height=100) + desc_entry.pack(fill="x", pady=(0, 15)) + + # Parent category + parent_label = ctk.CTkLabel( + form_frame, + text="Parent Category (optional):", + font=("Helvetica", 12, "bold") + ) + parent_label.pack(anchor="w", pady=(0, 5)) + + # Get all categories for parent dropdown + def get_all_categories(): + try: + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(f"{API_URL}/admin/categories", headers=headers) + + if response.status_code == 200: + categories = response.json() + parent_options = ["None"] + + for cat in categories: + # Don't include itself as a parent option when editing + if category and cat["id"] == category["id"]: + continue + parent_options.append(f"{cat['name']} (ID: {cat['id']})") + + return parent_options + else: + return ["None"] + except: + return ["None"] + + parent_options = get_all_categories() + parent_var = ctk.StringVar(value="None") + + parent_menu = ctk.CTkOptionMenu( + form_frame, + values=parent_options, + variable=parent_var, + width=460, + height=35, + dropdown_font=("Helvetica", 12) + ) + parent_menu.pack(fill="x", pady=(0, 20)) + + # Fill fields if editing + if category: + name_entry.insert(0, category.get("name", "")) + desc_entry.insert("1.0", category.get("description", "")) + + # Set parent category if it exists + if "parent_id" in category and category["parent_id"]: + for option in parent_options: + if f"(ID: {category['parent_id']})" in option: + parent_var.set(option) + break + + # Buttons + button_frame = ctk.CTkFrame(form_frame, fg_color="transparent") + button_frame.pack(fill="x", pady=(10, 0)) + + cancel_button = ctk.CTkButton( + button_frame, + text="Cancel", + font=("Helvetica", 12, "bold"), + width=100, + fg_color="#9e9e9e", + hover_color="#757575", + command=dialog.destroy + ) + cancel_button.pack(side="left", padx=(0, 10)) + + # Save function + def save_category(): + name = name_entry.get().strip() + description = desc_entry.get("1.0", "end-1c").strip() + parent = parent_var.get() + + if not name: + CTkMessagebox( + title="Warning", + message="Category name is required", + icon="warning" + ) + return + + # Extract parent ID if selected + parent_id = None + if parent != "None": + try: + parent_id = int(parent.split("ID: ")[1].split(")")[0]) + except: + pass + + # Prepare data + data = { + "name": name, + "description": description, + "parent_id": parent_id + } + + headers = {"Authorization": f"Bearer {access_token}"} + + try: + if category: # Editing existing category + response = requests.put( + f"{API_URL}/category/put/{category['id']}", + headers=headers, + data={"name": name} # Use data format for form data + ) + else: # Adding new category + response = requests.post( + f"{API_URL}/category/create", + headers=headers, + data={"name": name} # Use data format for form data + ) + + if response.status_code in [200, 201]: + dialog.destroy() + CTkMessagebox( + title="Success", + message="Category updated successfully" if category else "Category added successfully" + ) + fetch_categories() # Refresh the list + else: + error_detail = "Failed to save category" + try: + error_data = response.json() + if "detail" in error_data: + error_detail = error_data["detail"] + except: + pass + CTkMessagebox( + title="Error", + message=error_detail, + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Error", + message=f"An error occurred: {str(e)}", + icon="cancel" + ) + + save_button = ctk.CTkButton( + button_frame, + text="Save", + font=("Helvetica", 12, "bold"), + width=100, + fg_color="#2e8b57", + hover_color="#1f6e42", + command=save_category + ) + save_button.pack(side="right") + + # Connect UI elements to functions + search_button.configure(command=on_search) + add_button.configure(command=add_category) + edit_button.configure(command=edit_category) + delete_button.configure(command=delete_category) + + # Initial data fetch + def on_frame_shown(): + fetch_categories() + + frame.after(100, on_frame_shown) + + # Add refresh method to frame for external calls + def refresh_data(*args): + """Public method to refresh category data""" + fetch_categories() + + frame.refresh_data = refresh_data + + return frame \ No newline at end of file diff --git a/app/frontend/components/admin/dashboard.py b/app/frontend/components/admin/dashboard.py new file mode 100644 index 0000000..4ed2eb9 --- /dev/null +++ b/app/frontend/components/admin/dashboard.py @@ -0,0 +1,223 @@ +import customtkinter as ctk +from CTkMessagebox import CTkMessagebox +import requests +from PIL import Image, ImageTk +import os + +def admin_dashboard_frame(parent, switch_func, API_URL, access_token): + """ + Main admin dashboard with buttons to access different admin functions + """ + frame = ctk.CTkFrame(parent) + + # Store the token for later use + def update_token(new_token): + nonlocal access_token + access_token = new_token + + frame.update_token = update_token + + # Create main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section with gradient effect + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#1f538d") + header_frame.pack(pady=(0, 20), fill="x") + + # Header content + header_content = ctk.CTkFrame(header_frame, fg_color="transparent") + header_content.pack(padx=20, pady=20) + + # Try to load an admin icon if available + icon_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), + "static", "icons", "admin.png") + + try: + if os.path.exists(icon_path): + admin_icon = ctk.CTkImage(Image.open(icon_path), size=(64, 64)) + icon_label = ctk.CTkLabel(header_content, image=admin_icon, text="") + icon_label.pack(pady=(0, 10)) + except Exception as e: + print(f"Could not load admin icon: {e}") + + # Header text + ctk.CTkLabel( + header_content, + text="Admin Dashboard", + font=("Helvetica", 28, "bold"), + text_color="#ffffff" + ).pack(pady=(0, 5)) + + ctk.CTkLabel( + header_content, + text="Manage your application from this control panel", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack() + + # Welcome message with admin name + def get_admin_name(): + headers = {"Authorization": f"Bearer {access_token}"} + try: + resp = requests.get(f"{API_URL}/auth/profile", headers=headers) + if resp.status_code == 200: + profile = resp.json() + return profile.get("username", "Admin") + except Exception: + pass + return "Admin" + + welcome_frame = ctk.CTkFrame(main_container, corner_radius=10, fg_color="#f0f9ff", height=60) + welcome_frame.pack(fill="x", pady=(0, 20)) + + ctk.CTkLabel( + welcome_frame, + text=f"Welcome, {get_admin_name()}!", + font=("Helvetica", 16), + text_color="#1a1a1a" + ).pack(padx=20, pady=15) + + # Dashboard cards section + cards_frame = ctk.CTkFrame(main_container, fg_color="transparent") + cards_frame.pack(fill="both", expand=True) + + # Configure grid for cards + cards_frame.grid_columnconfigure(0, weight=1) + cards_frame.grid_columnconfigure(1, weight=1) + cards_frame.grid_rowconfigure(0, weight=1) + cards_frame.grid_rowconfigure(1, weight=1) + + # Check admin access + def check_admin(): + headers = {"Authorization": f"Bearer {access_token}"} + try: + resp = requests.get(f"{API_URL}/auth/role", headers=headers) + if resp.status_code == 200: + role_data = resp.json() + if role_data.get("role") != "admin": + CTkMessagebox( + title="Access Denied", + message="You need admin privileges to access this dashboard", + icon="cancel" + ) + switch_func("dashboard") + return False + else: + CTkMessagebox( + title="Error", + message="Failed to verify admin privileges", + icon="cancel" + ) + switch_func("login") + return False + except Exception as e: + CTkMessagebox( + title="Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + return False + return True + + # Create dashboard cards + def create_card(row, col, title, description, icon_text, command, color="#3a7ebf"): + card = ctk.CTkFrame(cards_frame, corner_radius=12, fg_color=color, border_width=1, border_color="#2a5a8f") + card.grid(row=row, column=col, padx=10, pady=10, sticky="nsew") + + # Create icon circle + icon_frame = ctk.CTkFrame(card, width=50, height=50, corner_radius=25, fg_color="#ffffff") + icon_frame.place(relx=0.1, rely=0.5, anchor="w") + + # Icon text (could be replaced with an actual icon) + ctk.CTkLabel( + icon_frame, + text=icon_text, + font=("Helvetica", 18, "bold"), + text_color=color + ).place(relx=0.5, rely=0.5, anchor="center") + + # Card content + content_frame = ctk.CTkFrame(card, fg_color="transparent") + content_frame.place(relx=0.6, rely=0.5, anchor="center") + + ctk.CTkLabel( + content_frame, + text=title, + font=("Helvetica", 18, "bold"), + text_color="#ffffff" + ).pack(pady=(0, 5)) + + ctk.CTkLabel( + content_frame, + text=description, + font=("Helvetica", 12), + text_color="#e6e6e6" + ).pack() + + # Make entire card clickable + card.bind("<Button-1>", lambda e: command() if check_admin() else None) + for widget in card.winfo_children(): + widget.bind("<Button-1>", lambda e: command() if check_admin() else None) + for subwidget in widget.winfo_children(): + subwidget.bind("<Button-1>", lambda e: command() if check_admin() else None) + + # Create the cards + create_card( + 0, 0, + "User Management", + "View and manage user accounts", + "👤", + lambda: switch_func("admin_user_management"), + "#3a7ebf" + ) + + create_card( + 0, 1, + "Shop Owner Management", + "Manage shop owner accounts", + "🏪", + lambda: switch_func("admin_shop_owner_management"), + "#2e8b57" + ) + + create_card( + 1, 0, + "Category Management", + "Manage product categories", + "🏷️", + lambda: switch_func("category"), + "#9c27b0" + ) + + create_card( + 1, 1, + "System Statistics", + "View system analytics and data", + "��", + lambda: CTkMessagebox(title="Coming Soon", message="System statistics will be available in a future update.", icon="info"), + "#e91e63" + ) + + # Footer with logout button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(20, 0)) + + # Logout button + logout_btn = ctk.CTkButton( + footer_frame, + text="Logout", + command=lambda: switch_func("login"), + fg_color="#f44336", + hover_color="#d32f2f", + font=("Helvetica", 14, "bold"), + corner_radius=8, + border_width=0, + height=40 + ) + logout_btn.pack(side="right") + + # Check admin access on load + frame.after(100, check_admin) + + return frame \ No newline at end of file diff --git a/app/frontend/components/admin/product_management.py b/app/frontend/components/admin/product_management.py new file mode 100644 index 0000000..f093acf --- /dev/null +++ b/app/frontend/components/admin/product_management.py @@ -0,0 +1,667 @@ +import customtkinter as ctk +from tkinter import ttk, messagebox +import requests +from datetime import datetime +import os +import base64 +from PIL import Image, ImageTk +from io import BytesIO +from CTkMessagebox import CTkMessagebox + +def admin_product_management_frame(parent, switch_func, API_URL, access_token): + """ + Admin dashboard component for managing products in the marketplace + """ + frame = ctk.CTkFrame(parent) + + # Store the token for later use + def update_token(new_token): + nonlocal access_token + access_token = new_token + + frame.update_token = update_token + + # Create main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#2e8b57") + header_frame.pack(fill="x", pady=(0, 20)) + + # Title and description + title_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + title_frame.pack(padx=20, pady=15) + + ctk.CTkLabel( + title_frame, + text="Product Management", + font=("Helvetica", 24, "bold"), + text_color="#ffffff" + ).pack(anchor="w") + + ctk.CTkLabel( + title_frame, + text="Manage marketplace products", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack(anchor="w") + + # Search and filter panel + filter_panel = ctk.CTkFrame(main_container, corner_radius=10, height=80) + filter_panel.pack(fill="x", pady=(0, 15)) + + # Left side - search + search_frame = ctk.CTkFrame(filter_panel, fg_color="transparent") + search_frame.pack(side="left", padx=20, pady=15, fill="y") + + ctk.CTkLabel(search_frame, text="Search:", font=("Helvetica", 12, "bold")).pack(side="left", padx=(0, 10)) + + search_entry = ctk.CTkEntry(search_frame, width=250, height=35, placeholder_text="Search products...") + search_entry.pack(side="left", padx=(0, 10)) + + search_button = ctk.CTkButton( + search_frame, + text="Search", + font=("Helvetica", 12, "bold"), + height=35, + corner_radius=8, + fg_color="#2e8b57", + hover_color="#1f6e42" + ) + search_button.pack(side="left") + + # Right side - actions + actions_frame = ctk.CTkFrame(filter_panel, fg_color="transparent") + actions_frame.pack(side="right", padx=20, pady=15, fill="y") + + filter_menu = ctk.CTkOptionMenu( + actions_frame, + values=["All Products", "Approved", "Pending", "Rejected"], + font=("Helvetica", 12), + width=150, + height=35, + dropdown_font=("Helvetica", 12) + ) + filter_menu.pack(side="left", padx=(0, 10)) + filter_menu.set("All Products") + + sort_menu = ctk.CTkOptionMenu( + actions_frame, + values=["Newest First", "Oldest First", "Price: High to Low", "Price: Low to High"], + font=("Helvetica", 12), + width=150, + height=35, + dropdown_font=("Helvetica", 12) + ) + sort_menu.pack(side="left") + sort_menu.set("Newest First") + + # Action buttons panel + action_buttons = ctk.CTkFrame(main_container, fg_color="transparent") + action_buttons.pack(fill="x", pady=(0, 15)) + + approve_button = ctk.CTkButton( + action_buttons, + text="Approve", + font=("Helvetica", 12, "bold"), + height=35, + width=100, + corner_radius=8, + fg_color="#4caf50", + hover_color="#388e3c" + ) + approve_button.pack(side="left", padx=(0, 10)) + + reject_button = ctk.CTkButton( + action_buttons, + text="Reject", + font=("Helvetica", 12, "bold"), + height=35, + width=100, + corner_radius=8, + fg_color="#f44336", + hover_color="#d32f2f" + ) + reject_button.pack(side="left", padx=(0, 10)) + + view_button = ctk.CTkButton( + action_buttons, + text="View Details", + font=("Helvetica", 12, "bold"), + height=35, + width=120, + corner_radius=8, + fg_color="#3a7ebf", + hover_color="#2a6da9" + ) + view_button.pack(side="left") + + # Data table frame + table_frame = ctk.CTkFrame(main_container, corner_radius=10) + table_frame.pack(fill="both", expand=True) + + # Configure Treeview style + style = ttk.Style() + style.theme_use("clam") # Use clam theme as base + + # Configure the Treeview colors to match app theme + style.configure( + "Treeview", + background="#2b2b2b", + foreground="#ffffff", + fieldbackground="#2b2b2b", + borderwidth=0, + rowheight=40 + ) + + # Configure the headings + style.configure( + "Treeview.Heading", + background="#2e8b57", + foreground="#ffffff", + borderwidth=0, + font=("Helvetica", 12, "bold") + ) + + # Selection color + style.map("Treeview", background=[("selected", "#2e8b57")]) + + # Create scrollbar + scrollbar = ttk.Scrollbar(table_frame) + scrollbar.pack(side="right", fill="y") + + # Create Treeview - Reordered columns + columns = ("id", "name", "category", "price", "owner", "status", "created_at", "image") + product_tree = ttk.Treeview( + table_frame, + columns=columns, + show="headings", + yscrollcommand=scrollbar.set + ) + + # Configure scrollbar + scrollbar.config(command=product_tree.yview) + + # Define column headings - Reordered + product_tree.heading("id", text="ID") + product_tree.heading("name", text="Product Name") + product_tree.heading("category", text="Category") + product_tree.heading("price", text="Price") + product_tree.heading("owner", text="Shop Owner") + product_tree.heading("status", text="Status") + product_tree.heading("created_at", text="Created At") + product_tree.heading("image", text="Image") + + # Define column widths and alignment - Reordered + product_tree.column("id", width=60, anchor="center") + product_tree.column("name", width=200) + product_tree.column("category", width=150) + product_tree.column("price", width=80, anchor="center") + product_tree.column("owner", width=150) + product_tree.column("status", width=100, anchor="center") + product_tree.column("created_at", width=150, anchor="center") + product_tree.column("image", width=80, anchor="center") + + # Add tag configurations for different status types + product_tree.tag_configure("approved", background="#1a472a") # Dark green + product_tree.tag_configure("pending", background="#5d4037") # Brown + product_tree.tag_configure("rejected", background="#b71c1c") # Dark red + + product_tree.pack(fill="both", expand=True, padx=5, pady=5) + + # Store images to prevent garbage collection + image_refs = [] + + # Footer with back button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(15, 0)) + + back_button = ctk.CTkButton( + footer_frame, + text="Back to Admin Dashboard", + command=lambda: switch_func("admin_dashboard"), + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + back_button.pack(side="left") + + # Function to fetch products + def fetch_products(status_filter=None, sort_option=None, search_term=None): + """ + Fetch products from backend with filters + """ + headers = {"Authorization": f"Bearer {access_token}"} + + # Build query params + params = {} + if status_filter and status_filter != "All Products": + params["status"] = status_filter.lower() + if sort_option: + if sort_option == "Newest First": + params["sort"] = "created_at_desc" + elif sort_option == "Oldest First": + params["sort"] = "created_at_asc" + elif sort_option == "Price: High to Low": + params["sort"] = "price_desc" + elif sort_option == "Price: Low to High": + params["sort"] = "price_asc" + if search_term: + params["search"] = search_term + + try: + response = requests.get(f"{API_URL}/admin/products", headers=headers, params=params) + + if response.status_code == 200: + # Clear current items + for item in product_tree.get_children(): + product_tree.delete(item) + + # Clear image references + image_refs.clear() + + products = response.json() + + for product in products: + # Format price + price = f"${product.get('price', 0):.2f}" + + # Format date + created_at = "N/A" + if "created_at" in product: + try: + dt = datetime.fromisoformat(product["created_at"].replace("Z", "+00:00")) + created_at = dt.strftime("%Y-%m-%d %H:%M") + except: + pass + + # Status for styling + status = product.get("status", "pending").lower() + + # Get image if available + img_text = "No Image" + if product.get("images") and len(product["images"]) > 0: + img_text = "Has Image" + + # Get category + category = product.get("category", {}).get("name", "Uncategorized") + + # Get owner name + owner = product.get("shop", {}).get("name", "Unknown") + + # Insert into tree - Reordered values to match new column order + item_id = product_tree.insert( + "", "end", + values=( + product["id"], + product.get("name", "Unnamed Product"), + category, + price, + owner, + status.capitalize(), + created_at, + img_text + ), + tags=(status,) + ) + + # Try to load the image + if product.get("images") and len(product["images"]) > 0: + try: + image_url = product["images"][0].get("image_url") + if image_url: + response = requests.get(image_url) + if response.status_code == 200: + img_data = response.content + img = Image.open(BytesIO(img_data)) + img = img.resize((30, 30)) + photo_img = ImageTk.PhotoImage(img) + + # Store reference to prevent garbage collection + image_refs.append(photo_img) + + # Use item id to identify which item to update + product_tree.item(item_id, values=( + product["id"], + product.get("name", "Unnamed Product"), + category, + price, + owner, + status.capitalize(), + created_at, + "" # Will be replaced by image + )) + + # Create image for the item + product_tree.item(item_id, image=photo_img) + except Exception as e: + print(f"[DEBUG] Error loading product image: {e}") + else: + CTkMessagebox(title="Error", message="Failed to fetch products", icon="cancel") + except Exception as e: + CTkMessagebox(title="Error", message=f"An error occurred: {str(e)}", icon="cancel") + + # Function to handle filter changes + def on_filter_change(*args): + status_filter = filter_menu.get() + sort_option = sort_menu.get() + search_term = search_entry.get() if search_entry.get() else None + fetch_products(status_filter, sort_option, search_term) + + # Function to handle search + def on_search(): + status_filter = filter_menu.get() + sort_option = sort_menu.get() + search_term = search_entry.get() if search_entry.get() else None + fetch_products(status_filter, sort_option, search_term) + + # Function to approve a product + def approve_product(): + selected_items = product_tree.selection() + if not selected_items: + CTkMessagebox(title="Warning", message="Please select a product to approve", icon="warning") + return + + product_id = product_tree.item(selected_items[0])["values"][0] + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.put( + f"{API_URL}/admin/products/{product_id}/approve", + headers=headers + ) + + if response.status_code == 200: + CTkMessagebox(title="Success", message="Product approved successfully", icon="check") + on_filter_change() # Refresh the list + else: + CTkMessagebox(title="Error", message="Failed to approve product", icon="cancel") + except Exception as e: + CTkMessagebox(title="Error", message=f"An error occurred: {str(e)}", icon="cancel") + + # Function to reject a product + def reject_product(): + selected_items = product_tree.selection() + if not selected_items: + CTkMessagebox(title="Warning", message="Please select a product to reject", icon="warning") + return + + product_id = product_tree.item(selected_items[0])["values"][0] + product_name = product_tree.item(selected_items[0])["values"][1] + + # Create rejection dialog + reject_dialog = ctk.CTkToplevel(frame) + reject_dialog.title("Reject Product") + reject_dialog.geometry("500x300") + reject_dialog.resizable(False, False) + reject_dialog.transient(frame) + reject_dialog.grab_set() + + # Dialog content + ctk.CTkLabel( + reject_dialog, + text=f"Reject Product: {product_name}", + font=("Helvetica", 18, "bold") + ).pack(pady=(20, 10)) + + ctk.CTkLabel( + reject_dialog, + text="Please provide a reason for rejection:", + font=("Helvetica", 14) + ).pack(pady=(0, 10)) + + reason_textbox = ctk.CTkTextbox(reject_dialog, width=400, height=100) + reason_textbox.pack(padx=20, pady=10) + + # Button frame + button_frame = ctk.CTkFrame(reject_dialog, fg_color="transparent") + button_frame.pack(pady=20) + + def on_cancel(): + reject_dialog.destroy() + + def on_submit(): + rejection_reason = reason_textbox.get("1.0", "end-1c").strip() + if not rejection_reason: + CTkMessagebox(title="Warning", message="Please provide a reason for rejection", icon="warning", parent=reject_dialog) + return + + headers = {"Authorization": f"Bearer {access_token}"} + data = {"reason": rejection_reason} + + try: + response = requests.put( + f"{API_URL}/admin/products/{product_id}/reject", + headers=headers, + json=data + ) + + if response.status_code == 200: + reject_dialog.destroy() + CTkMessagebox(title="Success", message="Product rejected successfully", icon="check") + on_filter_change() # Refresh the list + else: + CTkMessagebox(title="Error", message="Failed to reject product", icon="cancel", parent=reject_dialog) + except Exception as e: + CTkMessagebox(title="Error", message=f"An error occurred: {str(e)}", icon="cancel", parent=reject_dialog) + + ctk.CTkButton( + button_frame, + text="Cancel", + command=on_cancel, + font=("Helvetica", 12, "bold"), + fg_color="#9e9e9e", + hover_color="#757575", + width=120, + height=35 + ).pack(side="left", padx=10) + + ctk.CTkButton( + button_frame, + text="Reject Product", + command=on_submit, + font=("Helvetica", 12, "bold"), + fg_color="#f44336", + hover_color="#d32f2f", + width=150, + height=35 + ).pack(side="left", padx=10) + + # Function to view product details + def view_product_details(): + selected_items = product_tree.selection() + if not selected_items: + CTkMessagebox(title="Warning", message="Please select a product to view", icon="warning") + return + + product_id = product_tree.item(selected_items[0])["values"][0] + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.get(f"{API_URL}/admin/products/{product_id}", headers=headers) + + if response.status_code == 200: + product = response.json() + show_product_details(product) + else: + CTkMessagebox(title="Error", message="Failed to fetch product details", icon="cancel") + except Exception as e: + CTkMessagebox(title="Error", message=f"An error occurred: {str(e)}", icon="cancel") + + # Function to show product details dialog + def show_product_details(product): + detail_dialog = ctk.CTkToplevel(frame) + detail_dialog.title("Product Details") + detail_dialog.geometry("800x600") + detail_dialog.resizable(True, True) + detail_dialog.transient(frame) + detail_dialog.grab_set() + + # Create scrollable frame to handle large content + main_scroll = ctk.CTkScrollableFrame(detail_dialog) + main_scroll.pack(fill="both", expand=True, padx=20, pady=20) + + # Title + ctk.CTkLabel( + main_scroll, + text=product.get("name", "Unnamed Product"), + font=("Helvetica", 24, "bold") + ).pack(anchor="w", pady=(0, 20)) + + # Top section: Images and Basic Info + top_frame = ctk.CTkFrame(main_scroll, fg_color="transparent") + top_frame.pack(fill="x", pady=(0, 20)) + + # Left side - Images + image_frame = ctk.CTkFrame(top_frame, width=300, height=300, fg_color="#333") + image_frame.pack(side="left", padx=(0, 20)) + image_frame.pack_propagate(False) # Force dimensions + + # If has images, display the first one + if product.get("images") and len(product["images"]) > 0: + try: + image_url = product["images"][0].get("image_url") + if image_url: + response = requests.get(image_url) + if response.status_code == 200: + img_data = response.content + img = Image.open(BytesIO(img_data)) + + # Maintain aspect ratio + img.thumbnail((280, 280)) + + photo_img = ImageTk.PhotoImage(img) + + img_label = ctk.CTkLabel(image_frame, text="", image=photo_img) + img_label.image = photo_img # Keep reference + img_label.place(relx=0.5, rely=0.5, anchor="center") + else: + ctk.CTkLabel(image_frame, text="Image not available").place(relx=0.5, rely=0.5, anchor="center") + except: + ctk.CTkLabel(image_frame, text="Error loading image").place(relx=0.5, rely=0.5, anchor="center") + else: + ctk.CTkLabel(image_frame, text="No Image Available").place(relx=0.5, rely=0.5, anchor="center") + + # Right side - Basic info in a structured form + info_frame = ctk.CTkFrame(top_frame, fg_color="transparent") + info_frame.pack(side="left", fill="both", expand=True) + + # Fields grid + info_grid = ctk.CTkFrame(info_frame, fg_color="transparent") + info_grid.pack(fill="both", expand=True) + + def add_field(label, value, row): + label_widget = ctk.CTkLabel( + info_grid, + text=f"{label}:", + font=("Helvetica", 14, "bold"), + width=150, + anchor="e" + ) + label_widget.grid(row=row, column=0, sticky="e", padx=(0, 20), pady=8) + + value_widget = ctk.CTkLabel( + info_grid, + text=str(value), + font=("Helvetica", 14), + anchor="w" + ) + value_widget.grid(row=row, column=1, sticky="w", pady=8) + + # Basic details + add_field("Product ID", product["id"], 0) + add_field("Product Name", product.get("name", "Unnamed Product"), 1) + add_field("Price", f"${product.get('price', 0):.2f}", 2) + add_field("Status", product.get("status", "Pending").capitalize(), 3) + + # Shop information + shop = product.get("shop", {}) + add_field("Shop", shop.get("name", "Unknown"), 4) + add_field("Shop Owner", shop.get("owner_name", "Unknown"), 5) + + # Category + category = product.get("category", {}) + add_field("Category", category.get("name", "Uncategorized"), 6) + + # Created date + created_at = "N/A" + if "created_at" in product: + try: + dt = datetime.fromisoformat(product["created_at"].replace("Z", "+00:00")) + created_at = dt.strftime("%Y-%m-%d %H:%M") + except: + pass + add_field("Created At", created_at, 7) + + # Description section + desc_frame = ctk.CTkFrame(main_scroll, fg_color="transparent") + desc_frame.pack(fill="x", pady=(0, 20)) + + ctk.CTkLabel( + desc_frame, + text="Description", + font=("Helvetica", 18, "bold") + ).pack(anchor="w", pady=(0, 10)) + + desc_box = ctk.CTkTextbox(desc_frame, height=100) + desc_box.pack(fill="x") + desc_box.insert("1.0", product.get("description", "No description provided")) + desc_box.configure(state="disabled") # Make it read-only + + # Action buttons + buttons_frame = ctk.CTkFrame(main_scroll, fg_color="transparent") + buttons_frame.pack(fill="x", pady=(20, 0)) + + close_button = ctk.CTkButton( + buttons_frame, + text="Close", + command=detail_dialog.destroy, + height=35, + width=100 + ) + close_button.pack(side="right", padx=(10, 0)) + + # Add Approve/Reject buttons if product is pending + if product.get("status", "").lower() == "pending": + reject_button = ctk.CTkButton( + buttons_frame, + text="Reject", + command=lambda: detail_dialog.destroy() or reject_product(), + fg_color="#f44336", + hover_color="#d32f2f", + height=35, + width=100 + ) + reject_button.pack(side="right", padx=(10, 0)) + + approve_button = ctk.CTkButton( + buttons_frame, + text="Approve", + command=lambda: detail_dialog.destroy() or approve_product(), + fg_color="#4caf50", + hover_color="#388e3c", + height=35, + width=100 + ) + approve_button.pack(side="right", padx=(10, 0)) + + # Connect UI elements to functions + filter_menu.configure(command=on_filter_change) + sort_menu.configure(command=on_filter_change) + search_button.configure(command=on_search) + + # Connect action buttons + approve_button.configure(command=approve_product) + reject_button.configure(command=reject_product) + view_button.configure(command=view_product_details) + + # Initial data fetch + def on_frame_shown(): + fetch_products() + + frame.after(100, on_frame_shown) + + return frame \ No newline at end of file diff --git a/app/frontend/components/admin/shop_owner_management.py b/app/frontend/components/admin/shop_owner_management.py new file mode 100644 index 0000000..76f6a52 --- /dev/null +++ b/app/frontend/components/admin/shop_owner_management.py @@ -0,0 +1,478 @@ +from tkinter import ttk +import customtkinter as ctk +from CTkMessagebox import CTkMessagebox +import requests +from datetime import datetime + +def admin_shop_owner_management_frame(parent, switch_func, API_URL, access_token): + """ + Admin dashboard component for managing shop owners + """ + frame = ctk.CTkFrame(parent) + + # Store the token for later use + def update_token(new_token): + nonlocal access_token + access_token = new_token + + frame.update_token = update_token + + # Create main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#2e8b57") + header_frame.pack(fill="x", pady=(0, 20)) + + # Title and description + title_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + title_frame.pack(padx=20, pady=15) + + ctk.CTkLabel( + title_frame, + text="Shop Owner Management", + font=("Helvetica", 24, "bold"), + text_color="#ffffff" + ).pack(anchor="w") + + ctk.CTkLabel( + title_frame, + text="Manage shop owner accounts and their shops", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack(anchor="w") + + # Search and actions panel + control_panel = ctk.CTkFrame(main_container, corner_radius=10, height=80) + control_panel.pack(fill="x", pady=(0, 15)) + + # Left side - search + search_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + search_frame.pack(side="left", padx=20, pady=15, fill="y") + + ctk.CTkLabel(search_frame, text="Search:", font=("Helvetica", 12, "bold")).pack(side="left", padx=(0, 10)) + + search_entry = ctk.CTkEntry(search_frame, width=250, height=35, placeholder_text="Search by username or email") + search_entry.pack(side="left", padx=(0, 10)) + + search_button = ctk.CTkButton( + search_frame, + text="Search", + font=("Helvetica", 12, "bold"), + height=35, + corner_radius=8, + fg_color="#2e8b57", + hover_color="#1f6e42" + ) + search_button.pack(side="left") + + # Right side - actions + actions_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + actions_frame.pack(side="right", padx=20, pady=15, fill="y") + + view_shops_button = ctk.CTkButton( + actions_frame, + text="View Shops", + font=("Helvetica", 12, "bold"), + height=35, + width=120, + corner_radius=8, + fg_color="#3a7ebf", + hover_color="#2a6da9" + ) + view_shops_button.pack(side="left", padx=(0, 10)) + + refresh_button = ctk.CTkButton( + actions_frame, + text="Refresh", + font=("Helvetica", 12, "bold"), + height=35, + width=100, + corner_radius=8, + fg_color="#4caf50", + hover_color="#388e3c" + ) + refresh_button.pack(side="left", padx=(0, 10)) + + delete_button = ctk.CTkButton( + actions_frame, + text="Delete Owner", + font=("Helvetica", 12, "bold"), + height=35, + width=120, + corner_radius=8, + fg_color="#f44336", + hover_color="#d32f2f" + ) + delete_button.pack(side="left") + + # Data table frame + table_frame = ctk.CTkFrame(main_container, corner_radius=10) + table_frame.pack(fill="both", expand=True, pady=(15, 0)) + + # Configure Treeview style + style = ttk.Style() + style.theme_use("clam") # Use clam theme as base + + # Configure the Treeview colors to match app theme + style.configure( + "Treeview", + background="#2b2b2b", + foreground="#ffffff", + fieldbackground="#2b2b2b", + borderwidth=0, + rowheight=35 + ) + + # Configure the headings + style.configure( + "Treeview.Heading", + background="#2e8b57", + foreground="#ffffff", + borderwidth=0, + font=("Helvetica", 12, "bold") + ) + + # Selection color + style.map("Treeview", background=[("selected", "#2e8b57")]) + + # Create scrollbar + scrollbar = ttk.Scrollbar(table_frame) + scrollbar.pack(side="right", fill="y") + + # Create Treeview with styled tags + columns = ("id", "username", "email", "phone", "role") + owner_tree = ttk.Treeview( + table_frame, + columns=columns, + show="headings", + yscrollcommand=scrollbar.set + ) + + # Configure scrollbar + scrollbar.config(command=owner_tree.yview) + + # Define column headings + owner_tree.heading("id", text="ID") + owner_tree.heading("username", text="Username") + owner_tree.heading("email", text="Email") + owner_tree.heading("phone", text="Phone Number") + owner_tree.heading("role", text="Role") + + # Define column widths + owner_tree.column("id", width=60, anchor="center") + owner_tree.column("username", width=150) + owner_tree.column("email", width=200) + owner_tree.column("phone", width=150) + owner_tree.column("role", width=100, anchor="center") + + # Add tag configurations + owner_tree.tag_configure("owner", background="#252525") + owner_tree.tag_configure("highlight", background="#1a4731") + owner_tree.tag_configure("match", background="#1f3d5a") + + owner_tree.pack(fill="both", expand=True, padx=5, pady=5) + + # Footer with back button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(15, 0)) + + back_button = ctk.CTkButton( + footer_frame, + text="Back to Admin Dashboard", + command=lambda: switch_func("admin_dashboard"), + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + back_button.pack(side="left") + + # Add endpoints to the backend for these actions + def fetch_shop_owners(): + """Fetch all shop owners from the backend""" + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.get(f"{API_URL}/admin/owners", headers=headers) + + if response.status_code == 200: + # Clear existing items + for item in owner_tree.get_children(): + owner_tree.delete(item) + + shop_owners = response.json() + + # Filter users by role (only shop owners) + row_count = 0 + for owner in shop_owners: + # Format date + created_at = "N/A" + if "created_at" in owner: + try: + dt = datetime.fromisoformat(owner["created_at"].replace("Z", "+00:00")) + created_at = dt.strftime("%Y-%m-%d %H:%M") + except: + pass + + # Get number of shops if available + shop_count = len(owner.get("shops", [])) + + # Add with alternating tags for zebra striping + tag = "owner" if row_count % 2 == 0 else "" + if shop_count > 0: + tag = "highlight" # Highlight owners with shops + + owner_tree.insert( + "", "end", + values=( + owner["id"], + owner.get("username", "Unknown"), + owner.get("email", ""), + owner.get("phone_number", ""), + owner.get("role", "shop_owner") + ), + tags=(tag,) + ) + row_count += 1 + else: + CTkMessagebox( + title="Error", + message=response.json().get("detail", "Failed to fetch shop owners"), + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Connection Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + + def delete_shop_owner(): + """Delete (ban) the selected shop owner""" + selected_item = owner_tree.selection() + if not selected_item: + CTkMessagebox( + title="Warning", + message="Please select a shop owner to delete", + icon="warning" + ) + return + + owner_id = owner_tree.item(selected_item[0])["values"][0] + username = owner_tree.item(selected_item[0])["values"][1] + + # Confirm deletion + confirm = CTkMessagebox( + title="Confirm Deletion", + message=f"Are you sure you want to delete shop owner '{username}'?\n\nThis will also delete all their shops and products.\n\nThis action cannot be undone!", + icon="question", + option_1="Cancel", + option_2="Delete" + ) + + if confirm.get() != "Delete": + return + + try: + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.delete(f"{API_URL}/admin/owners/{owner_id}", headers=headers) + + if response.status_code == 200: + CTkMessagebox( + title="Success", + message="Shop owner deleted successfully", + icon="check" + ) + fetch_shop_owners() # Refresh the list + else: + CTkMessagebox( + title="Error", + message=response.json().get("detail", "Failed to delete shop owner"), + icon="cancel" + ) + except Exception as e: + CTkMessagebox( + title="Connection Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + + def view_shops(): + """View shops owned by the selected shop owner""" + selected_item = owner_tree.selection() + if not selected_item: + CTkMessagebox( + title="Warning", + message="Please select a shop owner first", + icon="warning" + ) + return + + # Get selected owner ID and username + owner_id = owner_tree.item(selected_item[0])['values'][0] + username = owner_tree.item(selected_item[0])['values'][1] + + # Create a popup to display shops + show_shops_dialog(owner_id, username) + + def show_shops_dialog(owner_id, username): + # Fetch shops for this owner + shops = fetch_owner_shops(owner_id) + + if not shops: + CTkMessagebox( + title="Shops", + message=f"Owner '{username}' has no shops.", + icon="info" + ) + return + + # Create popup dialog + dialog = ctk.CTkToplevel() + dialog.title(f"Shops owned by {username}") + dialog.geometry("600x400") + dialog.transient(frame) # Make dialog modal + dialog.grab_set() + + # Create a header + header_frame = ctk.CTkFrame(dialog, fg_color="#2e8b57", height=50) + header_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkLabel( + header_frame, + text=f"Shops owned by {username}", + font=("Helvetica", 16, "bold"), + text_color="white" + ).pack(padx=20, pady=10) + + # Create scrollable frame for shops + shops_frame = ctk.CTkScrollableFrame(dialog) + shops_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Add each shop as a card + for shop in shops: + shop_card = ctk.CTkFrame(shops_frame, corner_radius=10, fg_color="#333333") + shop_card.pack(fill="x", padx=5, pady=5) + + # Shop details + details_frame = ctk.CTkFrame(shop_card, fg_color="transparent") + details_frame.pack(fill="both", expand=True, padx=15, pady=15) + + # Shop name + ctk.CTkLabel( + details_frame, + text=shop.get("name", "Unnamed Shop"), + font=("Helvetica", 14, "bold"), + text_color="#2e8b57" + ).pack(anchor="w") + + # Description + description = shop.get("description", "No description") + ctk.CTkLabel( + details_frame, + text=f"Description: {description}", + font=("Helvetica", 12), + text_color="white", + wraplength=550 + ).pack(anchor="w", pady=(5, 0)) + + # Address + address = shop.get("address", "No address") + ctk.CTkLabel( + details_frame, + text=f"Address: {address}", + font=("Helvetica", 12), + text_color="white" + ).pack(anchor="w", pady=(5, 0)) + + # Created date + created_at = "N/A" + if "created_at" in shop: + try: + dt = datetime.fromisoformat(shop["created_at"].replace("Z", "+00:00")) + created_at = dt.strftime("%Y-%m-%d %H:%M") + except: + pass + + ctk.CTkLabel( + details_frame, + text=f"Created: {created_at}", + font=("Helvetica", 12), + text_color="#999999" + ).pack(anchor="w", pady=(5, 0)) + + # Close button + close_button = ctk.CTkButton( + dialog, + text="Close", + command=dialog.destroy, + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + close_button.pack(pady=(0, 10)) + + def fetch_owner_shops(owner_id): + try: + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(f"{API_URL}/admin/owners/{owner_id}/shops", headers=headers) + + if response.status_code == 200: + return response.json() + else: + CTkMessagebox( + title="Error", + message=response.json().get("detail", "Failed to fetch shops"), + icon="cancel" + ) + return [] + except Exception as e: + CTkMessagebox( + title="Connection Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + return [] + + def search_owners(): + """Search owners based on the search term""" + search_term = search_entry.get().lower() + if not search_term: + fetch_shop_owners() + return + + # Filter the treeview based on the search term + for item in owner_tree.get_children(): + values = owner_tree.item(item)["values"] + # Check if search term is in username or email + if (search_term in str(values[1]).lower() or + search_term in str(values[2]).lower()): + owner_tree.item(item, tags=("match",)) + else: + owner_tree.detach(item) # Hide non-matching items + + # Connect functions to buttons + search_button.configure(command=search_owners) + refresh_button.configure(command=fetch_shop_owners) + delete_button.configure(command=delete_shop_owner) + view_shops_button.configure(command=view_shops) + + # Fetch shop owners when the frame is shown + def on_frame_shown(): + fetch_shop_owners() + + frame.after(100, on_frame_shown) + + # Add refresh method to frame for external calls + def refresh_data(*args): + """Public method to refresh shop owner data""" + fetch_shop_owners() + + frame.refresh_data = refresh_data + + return frame \ No newline at end of file diff --git a/app/frontend/components/admin/user_management.py b/app/frontend/components/admin/user_management.py new file mode 100644 index 0000000..9e4c05b --- /dev/null +++ b/app/frontend/components/admin/user_management.py @@ -0,0 +1,313 @@ +from tkinter import ttk +import customtkinter as ctk +from CTkMessagebox import CTkMessagebox +import requests +from datetime import datetime + +def admin_user_management_frame(parent, switch_func, API_URL, access_token): + """ + Admin dashboard component for managing regular users + """ + frame = ctk.CTkFrame(parent) + + # Store the token for later use + def update_token(new_token): + nonlocal access_token + access_token = new_token + + frame.update_token = update_token + + # Create main container with padding + main_container = ctk.CTkFrame(frame, fg_color="transparent") + main_container.pack(padx=40, pady=30, fill="both", expand=True) + + # Header section + header_frame = ctk.CTkFrame(main_container, corner_radius=15, fg_color="#1f538d") + header_frame.pack(fill="x", pady=(0, 20)) + + # Title and description + title_frame = ctk.CTkFrame(header_frame, fg_color="transparent") + title_frame.pack(padx=20, pady=15) + + ctk.CTkLabel( + title_frame, + text="User Management", + font=("Helvetica", 24, "bold"), + text_color="#ffffff" + ).pack(anchor="w") + + ctk.CTkLabel( + title_frame, + text="View and manage user accounts", + font=("Helvetica", 14), + text_color="#e0e0e0" + ).pack(anchor="w") + + # Search and actions panel + control_panel = ctk.CTkFrame(main_container, corner_radius=10, height=80) + control_panel.pack(fill="x", pady=(0, 15)) + + # Left side - search + search_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + search_frame.pack(side="left", padx=20, pady=15, fill="y") + + ctk.CTkLabel(search_frame, text="Search:", font=("Helvetica", 12, "bold")).pack(side="left", padx=(0, 10)) + + search_entry = ctk.CTkEntry(search_frame, width=250, height=35, placeholder_text="Search by username or email") + search_entry.pack(side="left", padx=(0, 10)) + + search_button = ctk.CTkButton( + search_frame, + text="Search", + font=("Helvetica", 12, "bold"), + height=35, + corner_radius=8, + fg_color="#3a7ebf", + hover_color="#2a6da9" + ) + search_button.pack(side="left") + + # Right side - actions + actions_frame = ctk.CTkFrame(control_panel, fg_color="transparent") + actions_frame.pack(side="right", padx=20, pady=15, fill="y") + + refresh_button = ctk.CTkButton( + actions_frame, + text="Refresh", + font=("Helvetica", 12, "bold"), + height=35, + width=100, + corner_radius=8, + fg_color="#4caf50", + hover_color="#388e3c" + ) + refresh_button.pack(side="left", padx=(0, 10)) + + delete_button = ctk.CTkButton( + actions_frame, + text="Delete User", + font=("Helvetica", 12, "bold"), + height=35, + width=120, + corner_radius=8, + fg_color="#f44336", + hover_color="#d32f2f" + ) + delete_button.pack(side="left") + + # Data table frame + table_frame = ctk.CTkFrame(main_container, corner_radius=10) + table_frame.pack(fill="both", expand=True, pady=(15, 0)) + + # Configure Treeview style + style = ttk.Style() + style.theme_use("clam") # Use clam theme as base + + # Configure the Treeview colors to match app theme + style.configure( + "Treeview", + background="#2b2b2b", + foreground="#ffffff", + fieldbackground="#2b2b2b", + borderwidth=0, + rowheight=35 + ) + + # Configure the headings + style.configure( + "Treeview.Heading", + background="#1f538d", + foreground="#ffffff", + borderwidth=0, + font=("Helvetica", 12, "bold") + ) + + # Selection color + style.map("Treeview", background=[("selected", "#3a7ebf")]) + + # Create scrollbar + scrollbar = ttk.Scrollbar(table_frame) + scrollbar.pack(side="right", fill="y") + + # Create Treeview with styled tags + columns = ("id", "username", "email", "phone", "role") + user_tree = ttk.Treeview( + table_frame, + columns=columns, + show="headings", + yscrollcommand=scrollbar.set + ) + + # Configure scrollbar + scrollbar.config(command=user_tree.yview) + + # Define column headings + user_tree.heading("id", text="ID") + user_tree.heading("username", text="Username") + user_tree.heading("email", text="Email") + user_tree.heading("phone", text="Phone Number") + user_tree.heading("role", text="Role") + + # Define column widths + user_tree.column("id", width=60, anchor="center") + user_tree.column("username", width=150) + user_tree.column("email", width=200) + user_tree.column("phone", width=150) + user_tree.column("role", width=100, anchor="center") + + # Add tag configurations + user_tree.tag_configure("user", background="#252525") + user_tree.tag_configure("match", background="#1f3d5a") + + user_tree.pack(fill="both", expand=True, padx=5, pady=5) + + # Footer with back button + footer_frame = ctk.CTkFrame(main_container, fg_color="transparent", height=50) + footer_frame.pack(fill="x", pady=(15, 0)) + + back_button = ctk.CTkButton( + footer_frame, + text="Back to Admin Dashboard", + command=lambda: switch_func("admin_dashboard"), + font=("Helvetica", 12, "bold"), + height=40, + corner_radius=8, + fg_color="#555555", + hover_color="#444444" + ) + back_button.pack(side="left") + + # Add endpoints to the backend for these actions + def fetch_users(): + """Fetch all regular users from the backend""" + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.get(f"{API_URL}/admin/users", headers=headers) + + if response.status_code == 200: + # Clear existing items + for item in user_tree.get_children(): + user_tree.delete(item) + + users = response.json() + + # Filter users by role (only regular users, not admins) + row_count = 0 + for user in users: + if user["role"] == "buyer": + # Add row with alternating tags for zebra striping + tag = "user" if row_count % 2 == 0 else "" + user_tree.insert( + "", "end", + values=( + user["id"], + user["username"], + user["email"], + user.get("phone_number", "N/A"), + user["role"] + ), + tags=(tag,) + ) + row_count += 1 + else: + CTkMessagebox( + title="Error", + message=response.json().get("detail", "Failed to fetch users"), + icon="cancel" + ) + except requests.exceptions.RequestException as e: + CTkMessagebox( + title="Connection Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + + def delete_user(): + """Delete (ban) the selected user""" + selected_item = user_tree.selection() + if not selected_item: + CTkMessagebox( + title="Warning", + message="Please select a user to delete", + icon="warning" + ) + return + + user_id = user_tree.item(selected_item[0])["values"][0] + username = user_tree.item(selected_item[0])["values"][1] + + # Confirm deletion + confirm = CTkMessagebox( + title="Confirm Deletion", + message=f"Are you sure you want to delete user '{username}'?", + icon="question", + option_1="Cancel", + option_2="Delete" + ) + + if confirm.get() != "Delete": + return + + # Send delete request + headers = {"Authorization": f"Bearer {access_token}"} + + try: + response = requests.delete(f"{API_URL}/admin/users/{user_id}", headers=headers) + + if response.status_code == 200: + CTkMessagebox( + title="Success", + message="User deleted successfully", + icon="check" + ) + fetch_users() # Refresh the list + else: + CTkMessagebox( + title="Error", + message=response.json().get("detail", "Failed to delete user"), + icon="cancel" + ) + except requests.exceptions.RequestException as e: + CTkMessagebox( + title="Connection Error", + message=f"Failed to connect to server: {e}", + icon="cancel" + ) + + def search_users(): + """Search users based on the search term""" + search_term = search_entry.get().lower() + if not search_term: + fetch_users() + return + + # Filter the treeview based on the search term + for item in user_tree.get_children(): + values = user_tree.item(item)["values"] + # Check if search term is in username or email + if (search_term in str(values[1]).lower() or + search_term in str(values[2]).lower()): + user_tree.item(item, tags=("match",)) + else: + user_tree.detach(item) # Hide non-matching items + + # Connect functions to buttons + search_button.configure(command=search_users) + refresh_button.configure(command=fetch_users) + delete_button.configure(command=delete_user) + + # Fetch users when the frame is shown + def on_frame_shown(): + fetch_users() + + frame.after(100, on_frame_shown) + + # Add refresh method to frame for external calls + def refresh_data(*args): + """Public method to refresh user data""" + fetch_users() + + frame.refresh_data = refresh_data + + return frame \ No newline at end of file diff --git a/app/frontend/components/owner/owner_dashboard.py b/app/frontend/components/owner/owner_dashboard.py index 284d4d2..c806b86 100644 --- a/app/frontend/components/owner/owner_dashboard.py +++ b/app/frontend/components/owner/owner_dashboard.py @@ -307,8 +307,8 @@ def owner_dashboard_frame(parent, switch_func, API_URL, token): headers_frame.pack(fill="x") # Create the headers - header_texts = ["Image", "Title", "Product Name", "Price", "Quantity"] - header_widths = [0.15, 0.25, 0.25, 0.15, 0.2] # Adjusted proportional widths + header_texts = ["Image", "Title", "Product Name", "Category", "Price", "Quantity"] + header_widths = [0.15, 0.2, 0.2, 0.15, 0.15, 0.15] # Adjusted proportional widths for i, text in enumerate(header_texts): header_cell = ctk.CTkFrame(headers_frame, fg_color="transparent") @@ -386,11 +386,40 @@ def owner_dashboard_frame(parent, switch_func, API_URL, token): text_color="white", ) name_label.place(relx=0.5, rely=0.5, anchor="center") + + # Category cell + category_cell = ctk.CTkFrame(row, fg_color="transparent") + category_cell.place( + relx=sum(header_widths[:3]), rely=0, relwidth=header_widths[3], relheight=1 + ) + + category_name = "Uncategorized" + try: + # Debug the category data + print(f"DEBUG - Processing category for product {product.get('name')}:") + print(f" Category ID: {product.get('category_id')}") + print(f" Category object: {product.get('category')}") + + if product.get("category") is not None and product["category"].get("name"): + category_name = product["category"]["name"] + print(f" Using category name: {category_name}") + else: + print(" Using default: Uncategorized") + except Exception as e: + print(f"Error processing category: {e}") + + category_label = ctk.CTkLabel( + category_cell, + text=category_name, + font=("Helvetica", 12), + text_color="#A0A0A0", # Light gray color for category + ) + category_label.place(relx=0.5, rely=0.5, anchor="center") # Price cell price_cell = ctk.CTkFrame(row, fg_color="transparent") price_cell.place( - relx=sum(header_widths[:3]), rely=0, relwidth=header_widths[3], relheight=1 + relx=sum(header_widths[:4]), rely=0, relwidth=header_widths[4], relheight=1 ) price_label = ctk.CTkLabel( @@ -404,7 +433,7 @@ def owner_dashboard_frame(parent, switch_func, API_URL, token): # Quantity cell qty_cell = ctk.CTkFrame(row, fg_color="transparent") qty_cell.place( - relx=sum(header_widths[:4]), rely=0, relwidth=header_widths[4], relheight=1 + relx=sum(header_widths[:5]), rely=0, relwidth=header_widths[5], relheight=1 ) qty_label = ctk.CTkLabel( @@ -508,6 +537,27 @@ def owner_dashboard_frame(parent, switch_func, API_URL, token): ) if products_resp.status_code == 200: products = products_resp.json() + + # Debug output to see product data + print("DEBUG: Products response received:") + try: + # Print the raw response + print(f"Raw response: {products_resp.text[:200]}...") # First 200 chars only + + # Print product details for first few products + for i, p in enumerate(products[:2]): # Just print the first 2 to keep it manageable + print(f"Product {i+1}: {p.get('name')}") + print(f" Category ID: {p.get('category_id')}") + print(f" Has Category Object: {p.get('category') is not None}") + if p.get('category'): + print(f" Category Name: {p.get('category').get('name')}") + else: + print(" No category object") + print(" Full Product Data:") + print(f" {p}") + print("---") + except Exception as e: + print(f"Error while debugging product data: {e}") # Clear existing product rows clear_product_rows() diff --git a/app/frontend/components/owner/owner_orders.py b/app/frontend/components/owner/owner_orders.py index e2c59a7..a027d4b 100644 --- a/app/frontend/components/owner/owner_orders.py +++ b/app/frontend/components/owner/owner_orders.py @@ -279,14 +279,30 @@ def owner_orders_frame(parent, switch_func, API_URL, token): for i, item in enumerate(items[:2]): product_name = item.get("product", {}).get("name", "Unknown Product") quantity = item.get("quantity", 0) - + + # Get category name + category_name = "Uncategorized" + if item.get("product", {}).get("category", {}) and item["product"]["category"].get("name"): + category_name = item["product"]["category"]["name"] + + product_frame = ctk.CTkFrame(products_list, fg_color="transparent") + product_frame.pack(anchor="w", fill="x") + item_label = ctk.CTkLabel( - products_list, + product_frame, text=f"{product_name} (x{quantity})", font=("Helvetica", 12), text_color="#ccc", ) - item_label.pack(anchor="w") + item_label.pack(anchor="w", side="left") + + category_label = ctk.CTkLabel( + product_frame, + text=f"[{category_name}]", + font=("Helvetica", 10), + text_color="#A0A0A0", # Light gray for category + ) + category_label.pack(anchor="w", side="left", padx=(5, 0)) if len(items) > 2: more_label = ctk.CTkLabel( diff --git a/app/frontend/components/owner/owner_products.py b/app/frontend/components/owner/owner_products.py index fbcee05..a338623 100644 --- a/app/frontend/components/owner/owner_products.py +++ b/app/frontend/components/owner/owner_products.py @@ -152,15 +152,16 @@ def owner_products_frame(parent, switch_func, API_URL, token): headers_frame.pack_propagate(False) # Create the headers - header_texts = ["Image", "Title", "Product Name", "Price", "Quantity", "Action"] + header_texts = ["Image", "Title", "Product Name", "Category", "Price", "Quantity", "Action"] header_widths = [ 0.15, - 0.2, - 0.2, 0.15, + 0.15, + 0.15, + 0.1, 0.1, 0.2, - ] # Proportional widths adjusted for price + ] # Proportional widths adjusted for category column for i, text in enumerate(header_texts): header_cell = ctk.CTkFrame(headers_frame, fg_color="transparent") @@ -238,11 +239,40 @@ def owner_products_frame(parent, switch_func, API_URL, token): text_color="white", ) name_label.place(relx=0.5, rely=0.5, anchor="center") + + # Category cell + category_cell = ctk.CTkFrame(row, fg_color="transparent") + category_cell.place( + relx=sum(header_widths[:3]), rely=0, relwidth=header_widths[3], relheight=1 + ) + + category_name = "Uncategorized" + try: + # Debug the category data + print(f"DEBUG - Processing category for product {product.get('name')}:") + print(f" Category ID: {product.get('category_id')}") + print(f" Category object: {product.get('category')}") + + if product.get("category") is not None and product["category"].get("name"): + category_name = product["category"]["name"] + print(f" Using category name: {category_name}") + else: + print(" Using default: Uncategorized") + except Exception as e: + print(f"Error processing category: {e}") + + category_label = ctk.CTkLabel( + category_cell, + text=category_name, + font=("Helvetica", 12), + text_color="#A0A0A0", # Light gray color for category + ) + category_label.place(relx=0.5, rely=0.5, anchor="center") # Price cell price_cell = ctk.CTkFrame(row, fg_color="transparent") price_cell.place( - relx=sum(header_widths[:3]), rely=0, relwidth=header_widths[3], relheight=1 + relx=sum(header_widths[:4]), rely=0, relwidth=header_widths[4], relheight=1 ) price_label = ctk.CTkLabel( @@ -256,7 +286,7 @@ def owner_products_frame(parent, switch_func, API_URL, token): # Quantity cell qty_cell = ctk.CTkFrame(row, fg_color="transparent") qty_cell.place( - relx=sum(header_widths[:4]), rely=0, relwidth=header_widths[4], relheight=1 + relx=sum(header_widths[:5]), rely=0, relwidth=header_widths[5], relheight=1 ) qty_label = ctk.CTkLabel( @@ -270,7 +300,7 @@ def owner_products_frame(parent, switch_func, API_URL, token): # Action cell action_cell = ctk.CTkFrame(row, fg_color="transparent") action_cell.place( - relx=sum(header_widths[:5]), rely=0, relwidth=header_widths[5], relheight=1 + relx=sum(header_widths[:6]), rely=0, relwidth=header_widths[6], relheight=1 ) # Action buttons container (centered within action cell) @@ -440,6 +470,16 @@ def owner_products_frame(parent, switch_func, API_URL, token): ) if prod_resp.status_code == 200: products = prod_resp.json() + + # Debug output to see product data + print("DEBUG: Products response received in owner_products:") + for p in products[:2]: # Just print the first 2 to keep it manageable + print(f"Product: {p.get('name')}") + print(f"Category ID: {p.get('category_id')}") + print(f"Category: {p.get('category')}") + if p.get('category'): + print(f"Category Name: {p.get('category').get('name')}") + print("---") # Clear existing product rows clear_products_table() diff --git a/app/frontend/components/product/create_product.py b/app/frontend/components/product/create_product.py index adeb6f2..e0b6729 100644 --- a/app/frontend/components/product/create_product.py +++ b/app/frontend/components/product/create_product.py @@ -122,6 +122,66 @@ def create_product_frame(parent, switch_func, API_URL, token): stock_entry = create_form_field( left_col, "Stock Quantity", "Enter available stock..." ) + + # Category dropdown + category_frame = ctk.CTkFrame(left_col, fg_color="transparent") + category_frame.pack(fill="x", pady=(0, 15)) + + category_label = ctk.CTkLabel( + category_frame, text="Category", font=("Helvetica", 14), text_color="white" + ) + category_label.pack(anchor="w") + + # Dictionary to store category id -> name mapping + categories_data = {} + # List to store category names for the dropdown + category_options = ["Loading categories..."] + + category_dropdown = ctk.CTkOptionMenu( + category_frame, + values=category_options, + height=40, + corner_radius=8, + fg_color="#3b3b3b", + button_color=SHOPPING, + button_hover_color="#0096ff", + dropdown_fg_color="#3b3b3b", + text_color="white", + ) + category_dropdown.pack(fill="x", pady=(5, 0)) + + # Function to fetch categories from API + def fetch_categories(): + headers = {"Authorization": f"Bearer {token}"} + try: + response = requests.get(f"{API_URL}/category", headers=headers) + if response.status_code == 200: + categories = response.json() + + # Clear and update the categories dictionary + categories_data.clear() + category_names = [] + + if categories: + for cat in categories: + categories_data[cat["name"]] = cat["id"] + category_names.append(cat["name"]) + else: + category_names = ["No categories available"] + + # Update the dropdown + category_dropdown.configure(values=category_names) + if category_names: + category_dropdown.set(category_names[0]) + else: + messagebox.showerror( + "Error", + f"Failed to fetch categories. Status: {response.status_code}" + ) + except Exception as e: + messagebox.showerror("Error", f"Failed to fetch categories: {e}") + category_dropdown.configure(values=["Error loading categories"]) + category_dropdown.set("Error loading categories") # Description with multiline entry desc_frame = ctk.CTkFrame(left_col, fg_color="transparent") @@ -208,6 +268,10 @@ def create_product_frame(parent, switch_func, API_URL, token): price_val = price_entry.get().strip() desc_val = desc_entry.get("1.0", "end-1c").strip() stock_val = stock_entry.get().strip() + category_name = category_dropdown.get() + + # Get the category ID from the selected name + category_id = categories_data.get(category_name) if not title_val or not price_val or not stock_val: messagebox.showerror("Error", "Title, Price, and Stock are required") @@ -271,6 +335,7 @@ def create_product_frame(parent, switch_func, API_URL, token): "description": desc_val, "stock": stock_val, "shop_id": shop_id, # Include the shop_id + "category_id": category_id, # Include the category_id } files = {} if os.path.isfile(image_path.get()): @@ -325,5 +390,8 @@ def create_product_frame(parent, switch_func, API_URL, token): hover_color="#0096ff", ) submit_button.pack(side="right", fill="x", expand=True, padx=(5, 0)) + + # Fetch categories when frame is created + frame.after(100, fetch_categories) return frame diff --git a/app/frontend/components/product/edit_product.py b/app/frontend/components/product/edit_product.py index b3fcb0a..ea04ff3 100644 --- a/app/frontend/components/product/edit_product.py +++ b/app/frontend/components/product/edit_product.py @@ -124,6 +124,77 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): stock_entry = create_form_field( left_col, "Stock Quantity", "Enter available stock..." ) + + # Category dropdown + category_frame = ctk.CTkFrame(left_col, fg_color="transparent") + category_frame.pack(fill="x", pady=(0, 15)) + + category_label = ctk.CTkLabel( + category_frame, text="Category", font=("Helvetica", 14), text_color="white" + ) + category_label.pack(anchor="w") + + # Dictionary to store category id -> name mapping + categories_data = {} + # Dictionary to store category name -> id mapping + categories_id_to_name = {} + # List to store category names for the dropdown + category_options = ["Loading categories..."] + + category_dropdown = ctk.CTkOptionMenu( + category_frame, + values=category_options, + height=40, + corner_radius=8, + fg_color="#3b3b3b", + button_color=SHOPPING, + button_hover_color="#0096ff", + dropdown_fg_color="#3b3b3b", + text_color="white", + ) + category_dropdown.pack(fill="x", pady=(5, 0)) + + # Function to fetch categories from API + def fetch_categories(): + headers = {"Authorization": f"Bearer {token}"} + try: + response = requests.get(f"{API_URL}/category", headers=headers) + if response.status_code == 200: + categories = response.json() + + # Clear and update the categories dictionaries + categories_data.clear() + categories_id_to_name.clear() + category_names = [] + + if categories: + for cat in categories: + cat_id = cat["id"] + cat_name = cat["name"] + categories_data[cat_name] = cat_id + categories_id_to_name[cat_id] = cat_name + category_names.append(cat_name) + else: + category_names = ["No categories available"] + + # Update the dropdown + category_dropdown.configure(values=category_names) + + # If we have a category_id from the product, select it + if category_id and category_id in categories_id_to_name: + category_name = categories_id_to_name[category_id] + category_dropdown.set(category_name) + elif category_names: + category_dropdown.set(category_names[0]) + else: + messagebox.showerror( + "Error", + f"Failed to fetch categories. Status: {response.status_code}" + ) + except Exception as e: + messagebox.showerror("Error", f"Failed to fetch categories: {e}") + category_dropdown.configure(values=["Error loading categories"]) + category_dropdown.set("Error loading categories") # Description with multiline entry desc_frame = ctk.CTkFrame(left_col, fg_color="transparent") @@ -218,10 +289,15 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): buttons_frame.pack(fill="x", pady=(20, 0)) def submit_changes(): + nonlocal product_id, shop_id, category_id title_val = prod_title_entry.get().strip() price_val = price_entry.get().strip() desc_val = desc_entry.get("1.0", "end-1c").strip() stock_val = stock_entry.get().strip() + category_name = category_dropdown.get() + + # Get the category ID from the selected name + selected_category_id = categories_data.get(category_name, None) if not title_val or not price_val or not stock_val: messagebox.showerror("Error", "Title, Price, and Stock are required") @@ -265,9 +341,11 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): "description": desc_val, "stock": stock_val, "shop_id": shop_id, # Use the loaded shop_id - "category_id": category_id - or 1, # Use the loaded category_id or default to 1 } + + # Only add category_id to the data if it's a valid value + if selected_category_id is not None: + data["category_id"] = str(selected_category_id) # Ensure it's a string for the form data # Prepare files for upload if a new image was selected files = {} @@ -287,6 +365,18 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): # Make the API request headers = {"Authorization": f"Bearer {token}"} try: + # Debug output + print(f"DEBUG - Submitting product update:") + print(f" Product ID: {product_id}") + print(f" Name: {title_val}") + print(f" Shop ID: {shop_id}") + print(f" Category ID: {selected_category_id}") + + # Check if product_id is None + if product_id is None: + messagebox.showerror("Error", "Product ID is missing. Cannot update product.") + return + # Use PUT for updating existing product resp = requests.put( f"{API_URL}/product/put/{product_id}", @@ -378,6 +468,9 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): # Store shop_id and category_id for submission shop_id = product_data.get("shop_id") category_id = product_data.get("category_id") + + # Fetch categories to populate the dropdown with the current category selected + fetch_categories() # Reset image path since we're loading a product image_path = "" @@ -427,13 +520,26 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): # Load product data when frame is created if product_id: + print(f"DEBUG - Initial product_id: {product_id}") load_product_data() def refresh_data(new_product_id=None): nonlocal product_id - if new_product_id: + print(f"DEBUG - refresh_data called with new_product_id: {new_product_id}") + print(f"DEBUG - current product_id before refresh: {product_id}") + + if new_product_id is not None: product_id = new_product_id + print(f"DEBUG - Updated product_id to: {product_id}") load_product_data() + else: + print("DEBUG - No new product ID provided, using existing ID") + if product_id is not None: + load_product_data() + else: + print("WARNING - No product ID available, cannot load product data") + messagebox.showerror("Error", "No product ID available to load data") + go_back() frame.refresh_data = refresh_data diff --git a/app/frontend/components/product/view_product.py b/app/frontend/components/product/view_product.py index 7891b78..f958a74 100644 --- a/app/frontend/components/product/view_product.py +++ b/app/frontend/components/product/view_product.py @@ -20,35 +20,25 @@ BACKEND_HOST = "http://127.0.0.1:8000" def fix_url(url): - """ - If the provided URL does not start with http, assume it's a relative path. - Remove any unwanted prefix (e.g., "app/static/") and prepend the public URL. - """ - print(f"[DEBUG] fix_url received: {url}") - result = "" - + """Fix URLs to ensure they work with the backend""" + # Convert API URL to BACKEND_HOST for image URLs if not url: - print("[DEBUG] URL is None or empty") return "" - if url.startswith("http"): - print(f"[DEBUG] URL already starts with http, returning as is: {url}") - result = url - else: - # If the URL starts with "app/static/", remove that part. - prefix = "app/static/" - if url.startswith(prefix): - url = url[len(prefix) :] - print(f"[DEBUG] Removed prefix, now: {url}") - - # Prepend the public URL + # For URLs that need the backend host prefix + if not url.startswith("http"): + # Strip app/static/ prefix if present + if url.startswith("app/static/"): + url = url[len("app/static/"):] + + # Add the backend static prefix result = f"{BACKEND_HOST}/static/{url}" print(f"[DEBUG] Final URL after fixing: {result}") return result -def view_shop_frame(parent, switch_func, API_URL, token, shop_id): +def product_view_shop_frame(parent, switch_func, API_URL, token, shop_id): """ CustomTkinter-based frame to display shop details and products. """ diff --git a/app/frontend/main.py b/app/frontend/main.py index c499477..7c0eefc 100644 --- a/app/frontend/main.py +++ b/app/frontend/main.py @@ -13,6 +13,9 @@ from components.product.create_product import create_product_frame from components.product.view_product import view_product_frame from components.product.edit_product import edit_product_frame from components.admin.category import category_frame +from components.admin.dashboard import admin_dashboard_frame +from components.admin.user_management import admin_user_management_frame +from components.admin.shop_owner_management import admin_shop_owner_management_frame from components.dashboard import dashboard_frame from components.user_details import user_details_frame from components.user_orders import user_orders_frame @@ -95,6 +98,9 @@ def switch_frame(frame_name, *args): if user_role == "shop_owner": print("User is a shop owner, redirecting to owner_dashboard") frame_name = "owner_dashboard" + elif user_role == "admin": + print("User is an admin, redirecting to admin_dashboard") + frame_name = "admin_dashboard" else: print( f"User is not a shop owner (role: {user_role}), proceeding to dashboard" @@ -122,17 +128,11 @@ def switch_frame(frame_name, *args): print(f"Updating token for frame: {frame_key}") frame_obj.update_token(access_token) - # Role-based routing - print( - f"Checking role-based routing. Current user_role: {user_role}, target frame: {frame_name}" - ) - # Check if we're coming from an owner page trying to access the dashboard owner_going_to_shop = False if len(args) > 1 and args[1] == "from_owner_page" and frame_name == "dashboard": owner_going_to_shop = True print("Owner explicitly navigating to shop dashboard") - # Only apply role-based routing if not bypassing if not skip_role_check: if ( @@ -143,12 +143,33 @@ def switch_frame(frame_name, *args): # Redirect shop owners to the owner dashboard, but only if not explicitly navigating to shop print("Redirecting shop owner from dashboard to owner_dashboard") frame_name = "owner_dashboard" + elif ( + user_role == "shop_owner" + and frame_name == "view_product" + ): + # Always allow shop owners to view products + print("Shop owner viewing product - allowing access") + pass # Just proceed with view_product frame + # Explicitly allow all users to access view_shop and view_product + elif frame_name in ["view_shop", "view_product"]: + print(f"User with role {user_role} accessing {frame_name} - allowing access") + pass # Allow access to view_shop and view_product for all roles + elif user_role == "admin" and frame_name == "dashboard": + # Redirect admins to the admin dashboard + print("Redirecting admin from dashboard to admin_dashboard") + frame_name = "admin_dashboard" elif user_role != "shop_owner" and frame_name == "owner_dashboard": # Prevent non-owners from accessing owner dashboard print( f"Preventing non-owner (role: {user_role}) from accessing owner_dashboard" ) frame_name = "dashboard" + elif user_role != "admin" and frame_name in ["admin_dashboard", "admin_user_management", "admin_shop_owner_management"]: + # Prevent non-admins from accessing admin dashboards + print( + f"Preventing non-admin (role: {user_role}) from accessing admin pages" + ) + frame_name = "dashboard" frame = frames.get(frame_name) if frame is None: @@ -156,13 +177,37 @@ def switch_frame(frame_name, *args): return # If there's a refresh method on the frame and we have arguments, call it - if hasattr(frame, "refresh_data") and len(args) > 0: - print(f"Calling refresh_data on {frame_name}") - try: - frame.refresh_data(*args) - except Exception as e: - print(f"Error calling refresh_data on {frame_name}: {e}") - # Continue despite the error + if hasattr(frame, "refresh_data"): + # If the first argument is True, it explicitly requests a refresh + if len(args) > 0 and args[0] is True: + print(f"Explicitly refreshing {frame_name}") + frame.refresh_data(*args[1:] if len(args) > 1 else []) + # Otherwise, refresh when switching to admin components to keep data current + elif frame_name in ["admin_dashboard", "admin_user_management", "admin_shop_owner_management", "category", "owner_dashboard", "owner_products", "owner_orders"]: + print(f"Auto-refreshing {frame_name}") + frame.refresh_data() + # Add special handling for view_shop and view_product + elif frame_name == "view_shop": + print(f"*** DEBUG: Switching to view_shop with args: {args}") + # If we have a shop_id, pass it to refresh_data + if len(args) > 0 and args[0] is not None: + shop_id = args[0] + print(f"*** DEBUG: Refreshing view_shop with shop_id: {shop_id}") + frame.refresh_data(shop_id) + elif frame_name == "view_product": + print(f"*** DEBUG: Switching to view_product with args: {args}") + # If we have product data, pass it to refresh_data + if len(args) > 0 and args[0] is not None: + product_data = args[0] + print(f"*** DEBUG: Refreshing view_product with product data: {product_data}") + frame.refresh_data(product_data) + elif frame_name == "edit_product": + print(f"*** DEBUG: Switching to edit_product with args: {args}") + # If we have a product_id, pass it to refresh_data + if len(args) > 0 and args[0] is not None: + product_id = args[0] + print(f"*** DEBUG: Refreshing edit_product with product_id: {product_id}") + frame.refresh_data(product_id) # Make sure we have a valid token for authenticated pages if frame_name not in ["login", "register"] and ( @@ -202,6 +247,8 @@ def initialize_authenticated_frames(token): # Recreate authenticated frames with the new token frames["create_shop"] = create_shop_frame(root, switch_frame, API_URL, token) + # For view_shop and view_product, we'll initialize them with None parameters, which will be + # replaced when switch_frame is called with actual shop/product IDs frames["view_shop"] = view_shop_frame(root, switch_frame, API_URL, token, None) frames["create_product"] = create_product_frame(root, switch_frame, API_URL, token) frames["edit_product"] = edit_product_frame( @@ -220,6 +267,10 @@ def initialize_authenticated_frames(token): frames["user_details"] = user_details_frame(root, switch_frame, API_URL, token) frames["user_orders"] = user_orders_frame(root, switch_frame, API_URL, token) frames["user_payments"] = user_payments_frame(root, switch_frame, API_URL, token) + # Admin frames + frames["admin_dashboard"] = admin_dashboard_frame(root, switch_frame, API_URL, token) + frames["admin_user_management"] = admin_user_management_frame(root, switch_frame, API_URL, token) + frames["admin_shop_owner_management"] = admin_shop_owner_management_frame(root, switch_frame, API_URL, token) # Place all authenticated frames for key, frame in frames.items(): diff --git a/app/init.py b/app/init.py new file mode 100644 index 0000000..9624c1d --- /dev/null +++ b/app/init.py @@ -0,0 +1,21 @@ +import os +import sys +import subprocess + +# Add the current directory to the path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# Import admin initialization +from backend.scripts.admin_init import init_admin + +def main(): + """Main initialization function""" + print("Running initialization scripts...") + + # Initialize admin user + init_admin() + + print("Initialization complete!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/run_app.py b/run_app.py index 523ca5b..cf87b92 100644 --- a/run_app.py +++ b/run_app.py @@ -2,6 +2,7 @@ import threading import uvicorn from app.backend.main import app # Your FastAPI app import os +from app.backend.scripts.admin_init import init_admin def run_fastapi(): @@ -13,6 +14,11 @@ def start_tkinter_app(): if __name__ == "__main__": + # Initialize admin user + print("Initializing admin user...") + init_admin() + print("Admin initialization complete!") + # Start FastAPI in a separate thread fastapi_thread = threading.Thread(target=run_fastapi, daemon=True) fastapi_thread.start() -- GitLab