diff --git a/README.md b/README.md index e6f391a67fdeb8f900d22d3500df1922031dfafa..d45c9661804c16d0cf01e851fb27386d29230cd6 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/main.py b/app/backend/main.py index a3d7cf5fcb1c4aeda5bcb822f6236951f9420992..8359b4538585e168370676b6c59ce2ae7c870bb2 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -14,6 +14,7 @@ from app.backend.routes import ( order, payment, cart, + admin, ) from app.backend.database import init_db from core.config import settings @@ -41,6 +42,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 0000000000000000000000000000000000000000..698f4544a782367308cfbad89efe0de9a305439e --- /dev/null +++ b/app/backend/routes/admin.py @@ -0,0 +1,179 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select +from app.backend.models.models import User, Shop, Category +from app.backend.database import get_session +from app.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 diff --git a/app/backend/routes/category.py b/app/backend/routes/category.py index 69a0e433508bdbfa646dd23aa727ca9a97904291..056165ddee49627d7b42879b4da403dbc2876f92 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 app.backend.models.models import Category, User +from sqlmodel import Session, select +from app.backend.models.models import Category, User, Product from app.backend.schemas.category import CategoryRead from app.backend.database import get_session from app.backend.routes.auth import get_current_user +from typing import List +from sqlalchemy.exc import IntegrityError router = APIRouter() @@ -23,11 +25,26 @@ def create_category( ): 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 +68,27 @@ 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 +102,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 2108a7c7465749f29c6a1d643d8440c3baa4da05..23c3d86d508b3c9337503b5b644d0836897ea82d 100644 --- a/app/backend/routes/product.py +++ b/app/backend/routes/product.py @@ -1,5 +1,6 @@ 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 app.backend.models.models import ( Product, @@ -8,6 +9,7 @@ from app.backend.models.models import ( Shop, OrderItem, CartItem, + Category, ) from app.backend.schemas.product import ProductRead, ProductUpdate from app.backend.database import get_session @@ -50,7 +52,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}") @@ -96,6 +98,9 @@ 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) @@ -119,6 +124,9 @@ 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 @@ -127,7 +135,14 @@ 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 @@ -137,6 +152,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 @@ -147,7 +165,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), @@ -166,7 +184,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 79fb6d00805529d7b177b9e4e5a0e7ea9ec409cb..1b4cf0db23eddb5c8ceb2116252305c4dec86f93 100644 --- a/app/backend/schemas/category.py +++ b/app/backend/schemas/category.py @@ -1,10 +1,13 @@ +from pydantic import BaseModel, ConfigDict from sqlmodel import SQLModel from typing import Optional -class CategoryBase(SQLModel): +class CategoryBase(BaseModel): name: str + model_config = ConfigDict(from_attributes=True) + class CategoryCreate(CategoryBase): pass @@ -13,6 +16,8 @@ class CategoryCreate(CategoryBase): class CategoryRead(CategoryBase): id: int + model_config = ConfigDict(from_attributes=True) + class CategoryUpdate(SQLModel): name: Optional[str] = None diff --git a/app/backend/schemas/product.py b/app/backend/schemas/product.py index d76f9454afc8f55e6c45db69d8da40257944d21c..bb7d0b3ac7be9defd620cda17d2c343d0ad70ca2 100644 --- a/app/backend/schemas/product.py +++ b/app/backend/schemas/product.py @@ -1,8 +1,19 @@ from pydantic import BaseModel, ConfigDict -from typing import Optional, List +from typing import Optional, List, ForwardRef from datetime import datetime +class CategoryBase(BaseModel): + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +# Forward references for circular relationships +ProductImageRead = ForwardRef("ProductImageRead") + + class ProductBase(BaseModel): shop_id: int category_id: Optional[int] = None @@ -19,7 +30,8 @@ class ProductCreate(ProductBase): class ProductRead(ProductBase): id: int created_at: datetime - images: List["ProductImageRead"] = [] + images: List[ProductImageRead] = [] + category: Optional[CategoryBase] = None model_config = ConfigDict(from_attributes=True) diff --git a/app/backend/scripts/__init__.py b/app/backend/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d089d7f8cf9257c24fd29ef9ab1b7d17988c7b13 --- /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 0000000000000000000000000000000000000000..5a52ec5d0d2c1cf0d936b00a1a2a50701e13b785 --- /dev/null +++ b/app/backend/scripts/admin_init.py @@ -0,0 +1,47 @@ +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() diff --git a/app/frontend/components/admin/__init__.py b/app/frontend/components/admin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f6bf6cc3bef5c81fa3b5b6bfa49d799e93a9a2d --- /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 29c239edd842ce2052b965c4c4ad1bd86d79c67f..3504aa38db65021accc1a065211c3b3827e63d34 100644 --- a/app/frontend/components/admin/category.py +++ b/app/frontend/components/admin/category.py @@ -1,117 +1,491 @@ 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( + 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( - frame, text="Category Management", font=("Helvetica", 18, "bold") - ).pack(pady=10) + 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) - ctk.CTkLabel(frame, text="Category Name:").pack(pady=5) - entry_name = ctk.CTkEntry(frame) - entry_name.pack(pady=5) + 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) - ctk.CTkLabel(frame, text="Category ID (for update/delete):").pack(pady=5) - entry_id = ctk.CTkEntry(frame) - entry_id.pack(pady=5) + # 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") - else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to create category") + fetch_categories() # Refresh the list + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel", ) - except requests.exceptions.RequestException as e: - messagebox.showerror("Error", f"Failed to connect to server: {e}") - - 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") + 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_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") - else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to update category") + fetch_categories() # Refresh the list + elif response.status_code == 403: + CTkMessagebox( + title="Error", + message="Unauthorized. Admin access required.", + icon="cancel", ) + else: + 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") - else: - messagebox.showerror( - "Error", response.json().get("detail", "Failed to delete category") + 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: + 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) + + # 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) - 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) + # 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 0000000000000000000000000000000000000000..ac130e5027364e2fc6a36667cc5de065cd8d3d10 --- /dev/null +++ b/app/frontend/components/admin/category_management.py @@ -0,0 +1,561 @@ +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 diff --git a/app/frontend/components/admin/dashboard.py b/app/frontend/components/admin/dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..3a25bcfacc3de04fd8a5abf3e801ee23e5ff22d6 --- /dev/null +++ b/app/frontend/components/admin/dashboard.py @@ -0,0 +1,245 @@ +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 diff --git a/app/frontend/components/admin/product_management.py b/app/frontend/components/admin/product_management.py new file mode 100644 index 0000000000000000000000000000000000000000..cc9ba172dbd84487ed7a0d8f5863baaaa29fd717 --- /dev/null +++ b/app/frontend/components/admin/product_management.py @@ -0,0 +1,744 @@ +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 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 0000000000000000000000000000000000000000..4c872a016e2fbf85743be82699c337fc6c6dcf2e --- /dev/null +++ b/app/frontend/components/admin/shop_owner_management.py @@ -0,0 +1,496 @@ +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 diff --git a/app/frontend/components/admin/user_management.py b/app/frontend/components/admin/user_management.py new file mode 100644 index 0000000000000000000000000000000000000000..8d9a3f425e103ebb47e4dcc5a9a5c30cf777bc11 --- /dev/null +++ b/app/frontend/components/admin/user_management.py @@ -0,0 +1,321 @@ +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 diff --git a/app/frontend/components/owner/owner_dashboard.py b/app/frontend/components/owner/owner_dashboard.py index 284d4d2a1badf07dbc297be9ce867af26e77024c..341019a831f9f242bf189252cfa2d4ec491990ec 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") @@ -387,10 +387,39 @@ def owner_dashboard_frame(parent, switch_func, API_URL, token): ) 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( @@ -509,6 +538,35 @@ 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 e2c59a71665e1898cef297e26475cf89220b6c1c..9314034d7ec22293f90b460e80d1ef0a09eb3174 100644 --- a/app/frontend/components/owner/owner_orders.py +++ b/app/frontend/components/owner/owner_orders.py @@ -280,13 +280,31 @@ def owner_orders_frame(parent, switch_func, API_URL, token): 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 fbcee050ce37d1cf6b193b67e824a588d912b298..bf49d962a3ce3b5dd5db0de764b0fe7fe2e8150b 100644 --- a/app/frontend/components/owner/owner_products.py +++ b/app/frontend/components/owner/owner_products.py @@ -152,15 +152,24 @@ 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") @@ -239,10 +248,39 @@ def owner_products_frame(parent, switch_func, API_URL, token): ) 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 +294,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 +308,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) @@ -441,6 +479,18 @@ 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 adeb6f276996290857b141a571dd334668a54bc5..68e8f1b3eec7945a0849f6f32907ec2332b98d56 100644 --- a/app/frontend/components/product/create_product.py +++ b/app/frontend/components/product/create_product.py @@ -123,6 +123,66 @@ def create_product_frame(parent, switch_func, API_URL, token): 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") desc_frame.pack(fill="x", pady=(0, 15)) @@ -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()): @@ -326,4 +391,7 @@ def create_product_frame(parent, switch_func, API_URL, token): ) 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 b3fcb0ae50c621566a511ae665048bb082760cf0..c15a367bfc8e6c2add878643b77cf7db23976bfe 100644 --- a/app/frontend/components/product/edit_product.py +++ b/app/frontend/components/product/edit_product.py @@ -125,6 +125,77 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): 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") desc_frame.pack(fill="x", pady=(0, 15)) @@ -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,10 +341,14 @@ 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 = {} if image_path and os.path.isfile(image_path): @@ -287,6 +367,20 @@ 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}", @@ -379,6 +473,9 @@ def edit_product_frame(parent, switch_func, API_URL, token, product_id=None): 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 +524,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 7891b788b3c41d2cb16e078123310f016a51d0df..d7bac1aba0107975bd186228885cbfb2d81af604 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 c4994778f96ae003a4e8e21e2fb4d9a4e79ccde6..709fbd1ea42109022fd7ec98f4d76895ad86e183 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,36 @@ 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 +180,49 @@ 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 +262,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 +282,16 @@ 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 0000000000000000000000000000000000000000..3a3ba782a4c3ce80de2b592e45e347b11e9e2421 --- /dev/null +++ b/app/init.py @@ -0,0 +1,23 @@ +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() diff --git a/app/tests/test_search.py b/app/tests/test_search.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/requirements.txt b/requirements.txt index 1c5bb150f1acbbb5b29d84ddfede66a9ed8b7204..8bce77cb9555277ac11381eadb381a2ddc28e46e 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/run_app.py b/run_app.py index 523ca5b5dfc9c244f2bc780dcd31179b840aaaa0..e6c697f2d1706070b8f3f9589ce4c8c094618b13 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()