From 8786eb6d5b7b089fd9f9ad78ad3fbdabd16626eb Mon Sep 17 00:00:00 2001 From: "Nguyen12.Minh@live.uwe.ac.uk" <nguyen12.minh@live.uwe.ac.uk> Date: Sat, 15 Mar 2025 10:44:54 +0000 Subject: [PATCH] Minh --- app/frontend/components/admin/category.py | 117 +++++++++++++++ .../components/{ => auth}/forgot_pass.py | 0 app/frontend/components/{ => auth}/login.py | 31 ++-- .../components/{ => auth}/register.py | 44 +++--- .../components/product/create_product.py | 69 +++++++++ app/frontend/components/shop/create_shop.py | 41 +++-- app/frontend/components/shop/view_shop.py | 140 ++++++++++++++++++ app/frontend/main.py | 45 +++--- requirements.txt | Bin 978 -> 2142 bytes 9 files changed, 404 insertions(+), 83 deletions(-) create mode 100644 app/frontend/components/admin/category.py rename app/frontend/components/{ => auth}/forgot_pass.py (100%) rename app/frontend/components/{ => auth}/login.py (58%) rename app/frontend/components/{ => auth}/register.py (58%) create mode 100644 app/frontend/components/product/create_product.py create mode 100644 app/frontend/components/shop/view_shop.py diff --git a/app/frontend/components/admin/category.py b/app/frontend/components/admin/category.py new file mode 100644 index 0000000..29c239e --- /dev/null +++ b/app/frontend/components/admin/category.py @@ -0,0 +1,117 @@ +import customtkinter as ctk +import requests +from tkinter import messagebox + + +def category_frame(parent, switch_func, API_URL, access_token): + frame = ctk.CTkFrame(parent) + + ctk.CTkLabel( + frame, text="Category Management", font=("Helvetica", 18, "bold") + ).pack(pady=10) + + ctk.CTkLabel(frame, text="Category Name:").pack(pady=5) + entry_name = ctk.CTkEntry(frame) + entry_name.pack(pady=5) + + ctk.CTkLabel(frame, text="Category ID (for update/delete):").pack(pady=5) + entry_id = ctk.CTkEntry(frame) + entry_id.pack(pady=5) + + headers = {"Authorization": f"Bearer {access_token}"} if access_token else {} + + def create_category(): + name = entry_name.get().strip() + if not name: + messagebox.showwarning("Input Error", "Category name is required!") + return + + try: + response = requests.post( + f"{API_URL}/category", data={"name": name}, headers=headers + ) + if response.status_code == 200: + messagebox.showinfo( + "Success", f"Category '{name}' created successfully!" + ) + entry_name.delete(0, "end") + else: + messagebox.showerror( + "Error", response.json().get("detail", "Failed to create category") + ) + 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") + except requests.exceptions.RequestException as e: + messagebox.showerror("Error", f"Failed to connect to server: {e}") + + 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!") + return + if not name: + messagebox.showwarning( + "Input Error", "Category name is required for update!" + ) + return + + try: + response = requests.put( + f"{API_URL}/category/{category_id}", + data={"name": name}, + headers=headers, + ) + if response.status_code == 200: + messagebox.showinfo("Success", "Category updated successfully!") + entry_id.delete(0, "end") + entry_name.delete(0, "end") + else: + messagebox.showerror( + "Error", response.json().get("detail", "Failed to update category") + ) + except requests.exceptions.RequestException as e: + messagebox.showerror("Error", f"Failed to connect to server: {e}") + + def delete_category(): + category_id = entry_id.get().strip() + if not category_id.isdigit(): + messagebox.showwarning("Input Error", "Enter a valid Category ID!") + return + + try: + response = requests.delete( + f"{API_URL}/category/{category_id}", headers=headers + ) + if response.status_code == 200: + messagebox.showinfo("Success", "Category deleted successfully!") + entry_id.delete(0, "end") + else: + messagebox.showerror( + "Error", response.json().get("detail", "Failed to delete category") + ) + except requests.exceptions.RequestException as e: + messagebox.showerror("Error", f"Failed to connect to server: {e}") + + 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) + + return frame diff --git a/app/frontend/components/forgot_pass.py b/app/frontend/components/auth/forgot_pass.py similarity index 100% rename from app/frontend/components/forgot_pass.py rename to app/frontend/components/auth/forgot_pass.py diff --git a/app/frontend/components/login.py b/app/frontend/components/auth/login.py similarity index 58% rename from app/frontend/components/login.py rename to app/frontend/components/auth/login.py index bedad7b..64a9f81 100644 --- a/app/frontend/components/login.py +++ b/app/frontend/components/auth/login.py @@ -1,17 +1,12 @@ -import ttkbootstrap as tb -import ttkbootstrap.constants +import customtkinter as ctk from tkinter import messagebox import requests -# Global variable to store the access token -access_token = None - -def login_frame(parent, switch_func, api_url): - frame = tb.Frame(parent) +def login_frame(parent, switch_func, API_URL): + frame = ctk.CTkFrame(parent) def login(): - global access_token email = entry_email.get() password = entry_password.get() @@ -20,15 +15,13 @@ def login_frame(parent, switch_func, api_url): return response = requests.post( - f"{api_url}/auth/login", json={"email": email, "password": password} + f"{API_URL}/auth/login", json={"email": email, "password": password} ) try: response_data = response.json() if response.status_code == 200: access_token = response_data["access_token"] - print(f"Access Token: {access_token}") # Debugging line - messagebox.showinfo("Login Successful", f"Welcome back, {email}!") switch_func("create_shop", access_token) else: @@ -38,23 +31,21 @@ def login_frame(parent, switch_func, api_url): except requests.exceptions.JSONDecodeError: messagebox.showerror("Login Failed", "Server returned an invalid response.") - tb.Label(frame, text="Login", font=("Helvetica", 18, "bold")).pack(pady=10) + ctk.CTkLabel(frame, text="Login", font=("Helvetica", 18, "bold")).pack(pady=10) - tb.Label(frame, text="Email:").pack(pady=5) - entry_email = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Email:").pack(pady=5) + entry_email = ctk.CTkEntry(frame) entry_email.pack(pady=5) - tb.Label(frame, text="Password:").pack(pady=5) - entry_password = tb.Entry(frame, bootstyle="info", show="*") + ctk.CTkLabel(frame, text="Password:").pack(pady=5) + entry_password = ctk.CTkEntry(frame, show="*") entry_password.pack(pady=5) - btn_login = tb.Button(frame, text="Login", bootstyle="primary", command=login) - btn_login.pack(pady=15) + ctk.CTkButton(frame, text="Login", command=login).pack(pady=15) - tb.Button( + ctk.CTkButton( frame, text="Don't have an account? Register", - bootstyle="link", command=lambda: switch_func("register"), ).pack() diff --git a/app/frontend/components/register.py b/app/frontend/components/auth/register.py similarity index 58% rename from app/frontend/components/register.py rename to app/frontend/components/auth/register.py index b8aa074..e098132 100644 --- a/app/frontend/components/register.py +++ b/app/frontend/components/auth/register.py @@ -1,11 +1,10 @@ -import ttkbootstrap as tb -import ttkbootstrap.constants +import customtkinter as ctk from tkinter import messagebox -import requests # Import requests for API communication +import requests -def register_frame(parent, switch_func, api_url): # Added api_url parameter - frame = tb.Frame(parent) +def register_frame(parent, switch_func, API_URL): + frame = ctk.CTkFrame(parent) def register(): username = entry_username.get() @@ -28,9 +27,8 @@ def register_frame(parent, switch_func, api_url): # Added api_url parameter messagebox.showerror("Password Error", "Passwords do not match!") return - # Sending registration data to backend response = requests.post( - f"{api_url}/auth/signup", + f"{API_URL}/auth/signup", json={ "username": username, "email": email, @@ -43,7 +41,7 @@ def register_frame(parent, switch_func, api_url): # Added api_url parameter response_data = response.json() if response.status_code == 200: messagebox.showinfo("Registration Successful", f"Welcome, {username}!") - switch_func("login") # Switch to login after successful registration + switch_func("login") else: messagebox.showerror( "Registration Failed", response_data.get("detail", "Unknown error") @@ -53,37 +51,33 @@ def register_frame(parent, switch_func, api_url): # Added api_url parameter "Registration Failed", "Server returned an invalid response." ) - tb.Label(frame, text="Register", font=("Helvetica", 18, "bold")).pack(pady=10) + ctk.CTkLabel(frame, text="Register", font=("Helvetica", 18, "bold")).pack(pady=10) - tb.Label(frame, text="Username:").pack(pady=5) - entry_username = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Username:").pack(pady=5) + entry_username = ctk.CTkEntry(frame) entry_username.pack(pady=5) - tb.Label(frame, text="Email:").pack(pady=5) - entry_email = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Email:").pack(pady=5) + entry_email = ctk.CTkEntry(frame) entry_email.pack(pady=5) - tb.Label(frame, text="Phone Number:").pack(pady=5) - entry_phone = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Phone Number:").pack(pady=5) + entry_phone = ctk.CTkEntry(frame) entry_phone.pack(pady=5) - tb.Label(frame, text="Password:").pack(pady=5) - entry_password = tb.Entry(frame, bootstyle="info", show="*") + ctk.CTkLabel(frame, text="Password:").pack(pady=5) + entry_password = ctk.CTkEntry(frame, show="*") entry_password.pack(pady=5) - tb.Label(frame, text="Confirm Password:").pack(pady=5) - entry_confirm_password = tb.Entry(frame, bootstyle="info", show="*") + ctk.CTkLabel(frame, text="Confirm Password:").pack(pady=5) + entry_confirm_password = ctk.CTkEntry(frame, show="*") entry_confirm_password.pack(pady=5) - btn_register = tb.Button( - frame, text="Register", bootstyle="success", command=register - ) - btn_register.pack(pady=15) + ctk.CTkButton(frame, text="Register", command=register).pack(pady=15) - tb.Button( + ctk.CTkButton( frame, text="Already have an account? Login", - bootstyle="link", command=lambda: switch_func("login"), ).pack() diff --git a/app/frontend/components/product/create_product.py b/app/frontend/components/product/create_product.py new file mode 100644 index 0000000..b95400c --- /dev/null +++ b/app/frontend/components/product/create_product.py @@ -0,0 +1,69 @@ +import customtkinter as ctk +import requests +from tkinter import messagebox + + +def product_frame(parent, switch_func, API_URL, token): + frame = ctk.CTkFrame(parent) + + ctk.CTkLabel(frame, text="Product Management", font=("Helvetica", 18, "bold")).pack( + pady=10 + ) + + ctk.CTkLabel(frame, text="Product Name:").pack(pady=5) + entry_name = ctk.CTkEntry(frame) + entry_name.pack(pady=5) + + ctk.CTkLabel(frame, text="Price:").pack(pady=5) + entry_price = ctk.CTkEntry(frame) + entry_price.pack(pady=5) + + ctk.CTkLabel(frame, text="Stock:").pack(pady=5) + entry_stock = ctk.CTkEntry(frame) + entry_stock.pack(pady=5) + + def create_product(): + name = entry_name.get().strip() + price = entry_price.get().strip() + stock = entry_stock.get().strip() + + if not name or not price or not stock: + messagebox.showwarning("Input Error", "All fields are required!") + return + + try: + price = float(price) + stock = int(stock) + except ValueError: + messagebox.showwarning( + "Input Error", "Price must be a number and Stock must be an integer." + ) + return + + headers = {"Authorization": f"Bearer {token}"} + data = {"name": name, "price": price, "stock": stock} + + try: + response = requests.post(f"{API_URL}/products", json=data, headers=headers) + + if response.status_code == 200: + messagebox.showinfo( + "Success", f"Product '{name}' created successfully!" + ) + entry_name.delete(0, "end") + entry_price.delete(0, "end") + entry_stock.delete(0, "end") + else: + error_message = response.json().get( + "detail", "Failed to create product" + ) + messagebox.showerror("Error", error_message) + except requests.exceptions.RequestException as e: + messagebox.showerror("Error", f"Failed to connect to server: {e}") + + ctk.CTkButton(frame, text="Create Product", command=create_product).pack(pady=15) + ctk.CTkButton(frame, text="Back", command=lambda: switch_func("view_shop")).pack( + pady=5 + ) + + return frame diff --git a/app/frontend/components/shop/create_shop.py b/app/frontend/components/shop/create_shop.py index b4f8186..5319ebe 100644 --- a/app/frontend/components/shop/create_shop.py +++ b/app/frontend/components/shop/create_shop.py @@ -1,12 +1,11 @@ -import ttkbootstrap as tb -import ttkbootstrap.constants +import customtkinter as ctk from tkinter import messagebox, filedialog import requests import os -def create_shop_frame(parent, switch_func, api_url, token): - frame = tb.Frame(parent) +def create_shop_frame(parent, switch_func, API_URL, token): + frame = ctk.CTkFrame(parent) selected_file_path = [None] @@ -14,10 +13,10 @@ def create_shop_frame(parent, switch_func, api_url, token): file_path = filedialog.askopenfilename(title="Select Shop Image") if file_path: selected_file_path[0] = file_path - file_label.config(text=os.path.basename(file_path)) + file_label.configure(text=os.path.basename(file_path)) else: selected_file_path[0] = None - file_label.config(text="No file selected") + file_label.configure(text="No file selected") def create_shop(): name = entry_name.get() @@ -27,7 +26,7 @@ def create_shop_frame(parent, switch_func, api_url, token): messagebox.showwarning("Input Error", "Shop name is required!") return - url = f"{api_url}/shops" + url = f"{API_URL}/shops" data = {"name": name, "description": description} files = {} @@ -64,30 +63,30 @@ def create_shop_frame(parent, switch_func, api_url, token): except Exception as e: messagebox.showerror("Request Error", str(e)) - tb.Label(frame, text="Create Shop", font=("Helvetica", 18, "bold")).pack(pady=10) + ctk.CTkLabel(frame, text="Create Shop", font=("Helvetica", 18, "bold")).pack( + pady=10 + ) - tb.Label(frame, text="Shop Name:").pack(pady=5) - entry_name = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Shop Name:").pack(pady=5) + entry_name = ctk.CTkEntry(frame, placeholder_text="Enter shop name") entry_name.pack(pady=5) - tb.Label(frame, text="Description:").pack(pady=5) - entry_description = tb.Entry(frame, bootstyle="info") + ctk.CTkLabel(frame, text="Description:").pack(pady=5) + entry_description = ctk.CTkEntry(frame, placeholder_text="Enter shop description") entry_description.pack(pady=5) - tb.Button( - frame, text="Select Image", bootstyle="primary", command=select_file - ).pack(pady=5) - file_label = tb.Label(frame, text="No file selected") + ctk.CTkButton(frame, text="Select Image", command=select_file).pack(pady=5) + file_label = ctk.CTkLabel(frame, text="No file selected") file_label.pack(pady=5) - tb.Button(frame, text="Create Shop", bootstyle="success", command=create_shop).pack( - pady=15 - ) + ctk.CTkButton( + frame, text="Create Shop", fg_color="green", command=create_shop + ).pack(pady=15) - tb.Button( + ctk.CTkButton( frame, text="Back", - bootstyle="link", + fg_color="transparent", command=lambda: switch_func("login"), ).pack(pady=5) diff --git a/app/frontend/components/shop/view_shop.py b/app/frontend/components/shop/view_shop.py new file mode 100644 index 0000000..a5b9141 --- /dev/null +++ b/app/frontend/components/shop/view_shop.py @@ -0,0 +1,140 @@ +import customtkinter as ctk +import requests +from tkinter import messagebox +from PIL import Image, ImageTk +import io + + +def view_shop_frame(parent, switch_func, API_URL, token): + frame = ctk.CTkFrame(parent) + + # Title + title_label = ctk.CTkLabel(frame, text="Your Shop", font=("Helvetica", 18, "bold")) + title_label.pack(pady=10) + + # Shop Details + shop_name_label = ctk.CTkLabel( + frame, text="Shop Name: ", font=("Helvetica", 14, "bold") + ) + shop_name_label.pack(pady=5) + + shop_description_label = ctk.CTkLabel( + frame, text="Description: ", font=("Helvetica", 12) + ) + shop_description_label.pack(pady=5) + + shop_image_label = ctk.CTkLabel(frame, text="") # Placeholder for shop image + shop_image_label.pack(pady=10) + + # Product List Section + product_list_frame = ctk.CTkFrame(frame) + product_list_frame.pack(fill="both", expand=True, padx=10, pady=10) + + def fetch_shop_data(): + """Fetch the shop created by the logged-in user""" + headers = {"Authorization": f"Bearer {token}"} + try: + response = requests.get( + f"{API_URL}/shops/my-shop", headers=headers + ) # Adjust the endpoint as needed + if response.status_code == 200: + shop_data = response.json() + shop_name_label.configure(text=f"Shop Name: {shop_data['name']}") + shop_description_label.configure( + text=f"Description: {shop_data.get('description', 'No description')}" + ) + + # Load and display shop image if available + if "image_url" in shop_data and shop_data["image_url"]: + try: + image_response = requests.get(shop_data["image_url"]) + image = Image.open(io.BytesIO(image_response.content)) + image = image.resize((150, 150)) + img_tk = ImageTk.PhotoImage(image) + + shop_image_label.configure(image=img_tk, text="") + shop_image_label.image = img_tk + except Exception: + pass + + fetch_products(shop_data["id"]) # Fetch products for this shop + else: + messagebox.showerror("Error", "Failed to fetch shop details.") + except Exception as e: + messagebox.showerror("Error", f"Request error: {e}") + + def fetch_products(shop_id): + """Fetch products that belong to the user's shop""" + headers = {"Authorization": f"Bearer {token}"} + try: + response = requests.get( + f"{API_URL}/products?shop_id={shop_id}", headers=headers + ) + if response.status_code == 200: + products = response.json() + display_products(products) + else: + messagebox.showerror("Error", "Failed to fetch products.") + except Exception as e: + messagebox.showerror("Error", f"Request error: {e}") + + def display_products(products): + """Display the list of products in the shop""" + for widget in product_list_frame.winfo_children(): + widget.destroy() + + if not products: + ctk.CTkLabel( + product_list_frame, text="No products found.", font=("Helvetica", 12) + ).pack(pady=10) + return + + for product in products: + product_frame = ctk.CTkFrame(product_list_frame) + product_frame.pack(fill="x", padx=5, pady=5) + + # Product Image + img_label = ctk.CTkLabel(product_frame, text="") # Placeholder + img_label.pack(side="left", padx=5) + + # Load and display product image + if "images" in product and product["images"]: + image_url = product["images"][0]["image_url"] + try: + image_response = requests.get(image_url) + image = Image.open(io.BytesIO(image_response.content)) + image = image.resize((50, 50)) + img_tk = ImageTk.PhotoImage(image) + + img_label.configure(image=img_tk, text="") + img_label.image = img_tk + except Exception: + pass + + # Product Details + details_frame = ctk.CTkFrame(product_frame) + details_frame.pack(side="left", fill="x", expand=True, padx=10) + + ctk.CTkLabel( + details_frame, text=product["name"], font=("Helvetica", 12, "bold") + ).pack(anchor="w") + ctk.CTkLabel( + details_frame, + text=f"Price: ${product['price']:.2f}", + font=("Helvetica", 12), + ).pack(anchor="w") + + # Refresh Data Button + refresh_button = ctk.CTkButton(frame, text="Refresh", command=fetch_shop_data) + refresh_button.pack(pady=10) + + # Back Button + back_button = ctk.CTkButton( + frame, text="Back", command=lambda: switch_func("login") + ) + back_button.pack(pady=10) + + # Fetch shop data on load + fetch_shop_data() + + return frame diff --git a/app/frontend/main.py b/app/frontend/main.py index 938dbbb..2b14d6b 100644 --- a/app/frontend/main.py +++ b/app/frontend/main.py @@ -1,7 +1,10 @@ -import ttkbootstrap as tb -from components.login import login_frame -from components.register import register_frame +import customtkinter as ctk +from components.auth.login import login_frame +from components.auth.register import register_frame from components.shop.create_shop import create_shop_frame +from components.shop.view_shop import view_shop_frame +from components.product.create_product import product_frame +from components.admin.category import category_frame # Backend API URL API_URL = "http://127.0.0.1:8000" @@ -16,18 +19,14 @@ def switch_frame(frame_name, token=None): if token: access_token = token - if frame_name == "login": - login.tkraise() - elif frame_name == "register": - register.tkraise() - elif frame_name == "create_shop": - create_shop = create_shop_frame(root, switch_frame, API_URL, access_token) - create_shop.place(relx=0, rely=0.2, relwidth=1, relheight=0.8) - create_shop.tkraise() + frames.get(frame_name, login).tkraise() # Default to login if frame_name is invalid # Create main window -root = tb.Window(themename="superhero") +ctk.set_appearance_mode("dark") # Light, Dark, or System +ctk.set_default_color_theme("blue") + +root = ctk.CTk() root.title("Shopping App") root.geometry("900x800") @@ -35,13 +34,25 @@ root.geometry("900x800") login = login_frame(root, switch_frame, API_URL) register = register_frame(root, switch_frame, API_URL) create_shop = create_shop_frame(root, switch_frame, API_URL, access_token) - -# Place all frames responsively within the window. -# Adjust relx, rely, relwidth, and relheight as needed for your layout. -for frame in (login, register, create_shop): +view_shop = view_shop_frame(root, switch_frame, API_URL, access_token) +product = product_frame(root, switch_frame, API_URL, access_token) +category = category_frame(root, switch_frame, API_URL, access_token) + +# Store frames in a dictionary for easier management +frames = { + "login": login, + "register": register, + "create_shop": create_shop, + "create_product": product, + "category": category, + "view_shop": view_shop, +} + +# Place all frames responsively +for frame in frames.values(): frame.place(relx=0, rely=0.2, relwidth=1, relheight=0.8) # Show the login frame first -switch_frame("login") +switch_frame("view_shop") root.mainloop() diff --git a/requirements.txt b/requirements.txt index c8b79503eac507894fdbccb6dc8052bfbf436fdd..098a8e9d014d631acb415fded564d5774243a0a0 100644 GIT binary patch literal 2142 zcmezWFOeaSA&()Sp@bokp@booA%#Jgp@gB5p@1Qkp_svz!Ir^*L65<lL65<JftP`c z0i?c?A(J5=EN8-?$6x`MOJYc7C<5y(0m~XQ=rI_9F-T`JLn=cNLkUABLmERSSl)=i zfWe5tltGWdkU@{Zn86TiLo!1eTm?wn5Ntb4E`uSFp@^ZFA(f$oK^N@uB8FUsM1~xO zOol434InpzYzDavrVAvS%#aPX735x!xfW2{@)>d%@)?R462azxbc1{h(rpG+0rOKn zLpoSp0Ye5uCD<$z29P?CdLyXXQifuN5{7(+T!s>cY=%sRJaDKMfmMTiiU^MsuurlX zQow#mWk_a#gc`^+kQ*W4ox+gEP|Q%kP{~ljkin48kOwviWC|iqQo%0IWXNHNXDEY) z1;})eJ0LcL)Ppd@%ru5XuqzW83ZO9oveS^k5E>VdP(xLv%a9BXV_5irbeMzv3<{G} zhE#?;h8(a@Kq^4t4+>+5Nf`_vvq9n@U53yw1Eq-!h7yJnh609Ua15k^{RYwpQU@^| zrUK*_P)s4xq#1)Ag9+FLFf|om{UE(Y;8Y2*J(B?xhKXP~h`S)6kjYTOP|N@dr#yyq zaLUPN0L322ouIG+VTejEa6D!)Br+HwnWD#F3bq}T`idAp@tDt0&XC8T%K$Q27aV6G zvp{x&+ypVp7aHrO3<V6q42cYB3=m(#R6){FE<*}KDMJxM4%j}BZcwPe<SH48844M4 z7(j6XN)dSskkkW8#SnXu(~2cH2g33%NKF<)F+3%K>@@`EaZv1o;-r8fkpYxf5*gCL z{!RzG!3Z2{5Ep<{6f+cq(?b$i7UVimdW6IWC{^Wvb5%K179<bCkT3wH=41wtDUh6u zoJL_bR6=70R9+;5O##IR#HEmY2{Hu|dk{6c45<ti3?<+kie82x^kO$(7h0l#Oai3_ zNGb#A0_8+VIzqQE9wpU+)Ea?HDu@jM43!M&47uQZ0ZM0}lmJp=$N-8Dh>0LIUJT(3 zA!z<X$oeu=G6XXOGWdX1fI<+0A@PJL6Lc9;z-2fn7nZ@*LedSU+FXWGh8%E742c6o zxdYMxDwRQcK(32qaAfd>+78NZ5P6Vn5ko3NAwwy+mO%EOF}TDx2Iq_-hD?TJh772C zKq(F6cSst6$$?@jp8=G!L8T?aC58+j^^mYEX2@VjWdNlpP<fip0ICCWp{9azA*>uI zX2=8AE};4aQa^!03uF!?L?I#R$dChe6{ysNl;WUx0r||B0a7PGa#t=xKDcZ~<Ux=c zBL;}yK&3pWCW4fCpqdjD!ypwV43Lllg%YeLg`{whtRc9Jh4>313-MDtH1&dFuozsw zg35i6nV=fR5FDxyU8M}j`4?2DKyn2nZ$U~8P<@xgU<?i;knccY0Fy5R#|o$%fYc=* yR~dtIH$+7_xFrCJH&8f&{0GSmps)m~g~VSuLn=cOxEukM6`<M}rV8XDh)DptEjSAR literal 978 zcmYey%gZlGEJ;n#EvYO>Ew;5a&@<OF;7ZJ^%*?m7HPN%sGvG=}E~+djv9&eUGtx8S zN=_{*$xO?%wKXy@GSxHGGdAQ(P6P1`^$gAR47rjs5{rscOLX({i*gflGOJRHKsrr8 zGC7&a*|xS8dWL!yT*>)4`9+Dji69$I^vt-xcI2lQB^G2<+S;0!=o#o4ai!!H7gUyH z<mcJi8iC!FnwyxJ6JM5?lbMoOl3xT8HqtZTN=qy*Ni4_&nPq5bs%K~j6VXl10gD>w znRBHVrKaZPq=G$SsAtHPVQ2`}V4`Qhl~GbskepwXYHMq#2T}{-m*nT?fCI!#&jhTX z0;Jr?LeG#ZGbJw(<SRo1uFR6+l*GLB)S~>-VwiKhGV`($jlc@@Ou2Fsi?UPl%ky+I zOLPk=L81nF23)?0McJhV!HH?95D_D;+?3Ly9FP@;dPZEimBocQy2<%@d8x@I`9-?0 zV6fB!`8U5Ps~8k0#(IVZdWNQ41&PJQIhjcy*PH8^a1~_c<m8vz+8P?_8R{8u6;vh{ zfZ_xaTt-|4l_`mNC7H<}=NcO5nZX5gQ!7e}5@9L92xK@ywQg~0Nl9j2I@p(>K!6z& z4+%IUJtK2HBd&nT^xV|E60j;mOFcubfJ(3M5U5oko^NGvpbyBUpg4edOE)FIBsH%L zBxs;#$ORS5EzK#(EJ!RW0fmWyo{<4pKxL$(uaB**nI0%{6r~oHrWQkNGB(mP<|@ie z&akyLG}g1!GXZmSK~b2U2{ypcSkH*7I3qPDCo?ZSBQY0bnW>%$S8-luS{f*=8|oSB z8FB>&`Z(q!XQbv<f}CuiXKcY$T$q!apOOlW8UsBeW3J+o#G;(kl9E)A!%WQd47f^4 zvXk=jONvX15(_{EgJOjXl%a}1N(@c)j6l51y!80gijvg4;>`TKVo>ff1ZAjFNL-qN zg4=+rv?wPhGszfayRn`DS7}*ha(+=B$QWZ#LM=}$NzO>i%mHOpP*OGFDo;%+&QH!x L1qFnmsh$A<%(^IC -- GitLab