From f2245f5f07e10e2aab12b0ee1291af4c09d2bc7d Mon Sep 17 00:00:00 2001 From: nn2-minh <Nguyen12.Minh@live.uwe.ac.uk> Date: Tue, 25 Mar 2025 21:55:18 +0700 Subject: [PATCH] Add user_details and orders --- app/frontend/components/auth/login.py | 13 +- app/frontend/components/dashboard.py | 67 ++++++ app/frontend/components/user_details.py | 297 ++++++++++++++++++++++++ app/frontend/components/user_orders.py | 228 ++++++++++++++++++ app/frontend/main.py | 8 +- requirements.txt | Bin 1045 -> 2206 bytes 6 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 app/frontend/components/user_details.py create mode 100644 app/frontend/components/user_orders.py diff --git a/app/frontend/components/auth/login.py b/app/frontend/components/auth/login.py index f2330a5..79f360b 100644 --- a/app/frontend/components/auth/login.py +++ b/app/frontend/components/auth/login.py @@ -3,6 +3,7 @@ from tkinter import messagebox from PIL import Image from utils.api_requests import login_api # Import the login function from login_api.py + def login_frame(parent, switch_func, API_URL): # Create a container frame to hold both left (empty) and right (login) frames container = ctk.CTkFrame(parent) @@ -16,10 +17,12 @@ def login_frame(parent, switch_func, API_URL): # Load and display the image image_path = "app/static/front_end_img/login.jpg" # Change this to your image path - img = ctk.CTkImage(light_image=Image.open(image_path), size=(1000, 1000)) # Resize as needed + img = ctk.CTkImage( + light_image=Image.open(image_path), size=(1000, 1000) + ) # Resize as needed image_label = ctk.CTkLabel(left_frame, image=img, text="") # No text, only image image_label.place(relwidth=1, relheight=1) - + # Right login frame right_frame = ctk.CTkFrame(container) right_frame.grid(row=0, column=1, sticky="nsew") @@ -38,14 +41,16 @@ def login_frame(parent, switch_func, API_URL): if status_code == 200: access_token = response_data.get("access_token") messagebox.showinfo("Login Successful", f"Welcome back, {email}!") - switch_func("view_shop", access_token) + switch_func("dashboard", access_token) print(f"Access Token in login: {access_token}") # Debugging line else: messagebox.showerror( "Login Failed", response_data.get("detail", "Invalid credentials") ) - ctk.CTkLabel(right_frame, text="Login", font=("Helvetica", 18, "bold")).pack(pady=10) + ctk.CTkLabel(right_frame, text="Login", font=("Helvetica", 18, "bold")).pack( + pady=10 + ) ctk.CTkLabel(right_frame, text="Email:").pack(pady=5) entry_email = ctk.CTkEntry(right_frame) diff --git a/app/frontend/components/dashboard.py b/app/frontend/components/dashboard.py index 934e685..75622c9 100644 --- a/app/frontend/components/dashboard.py +++ b/app/frontend/components/dashboard.py @@ -57,6 +57,73 @@ def dashboard_frame(parent, switch_func, API_URL, token): ) search_button.place(relx=0.71, rely=0.25, relwidth=0.08, relheight=0.5) + # ------------- USER & CART ICONS (Top-Right) ------------- + def open_user_details(): + # Switch to user_details.py screen or run it + switch_func("user_details") + # Alternatively, use os.system("python user_details.py") + + def open_cart_details(): + # Switch to cart_shopping.py screen or run it + switch_func("cart_shopping") + # Alternatively, use os.system("python cart_shopping.py") + + # Try loading icon images; update paths as needed. + try: + user_image = Image.open("path/to/user_icon.png").resize((30, 30)) + user_icon = ImageTk.PhotoImage(user_image) + except Exception as e: + print(f"User icon load error: {e}") + user_icon = None + + try: + cart_image = Image.open("path/to/cart_icon.png").resize((30, 30)) + cart_icon = ImageTk.PhotoImage(cart_image) + except Exception as e: + print(f"Cart icon load error: {e}") + cart_icon = None + + if user_icon: + user_button = ctk.CTkButton( + header_frame, + image=user_icon, + text="", + fg_color="transparent", + command=open_user_details, + ) + switch_func("user_details") + user_button.image = user_icon + user_button.place(relx=0.82, rely=0.25, relwidth=0.08, relheight=0.5) + else: + user_button = ctk.CTkButton( + header_frame, + text="User", + fg_color="white", + text_color="black", + command=open_user_details, + ) + user_button.place(relx=0.82, rely=0.25, relwidth=0.08, relheight=0.5) + + if cart_icon: + cart_button = ctk.CTkButton( + header_frame, + image=cart_icon, + text="", + fg_color="transparent", + command=open_cart_details, + ) + cart_button.image = cart_icon + cart_button.place(relx=0.91, rely=0.25, relwidth=0.08, relheight=0.5) + else: + cart_button = ctk.CTkButton( + header_frame, + text="Cart", + fg_color="white", + text_color="black", + command=open_cart_details, + ) + cart_button.place(relx=0.91, rely=0.25, relwidth=0.08, relheight=0.5) + # ------------- MIDDLE (Featured/Top Shops) ------------- middle_frame = ctk.CTkFrame(frame, fg_color="transparent") middle_frame.place(relx=0, rely=0.1, relwidth=1, relheight=0.25) diff --git a/app/frontend/components/user_details.py b/app/frontend/components/user_details.py new file mode 100644 index 0000000..7a49032 --- /dev/null +++ b/app/frontend/components/user_details.py @@ -0,0 +1,297 @@ +import customtkinter as ctk +import requests +from PIL import Image, ImageTk +from tkinter import messagebox, filedialog +import io + +SHOPPING = "#00c1ff" + +def user_details_frame(parent, switch_func, API_URL, token): + """ + A two-column user details page that matches the dashboard color scheme: + - Top bar with SHOPPING color (#00c1ff) + - Left sidebar with a dark background (#2b2b2b) + - Right content area (profile form, profile picture) + """ + + # Main container frame (transparent background, like dashboard) + frame = ctk.CTkFrame(parent, fg_color="transparent") + + # ----------------- TOP BAR ----------------- + top_bar = ctk.CTkFrame(frame, fg_color=SHOPPING, height=40) + top_bar.pack(fill="x", side="top") + + top_label = ctk.CTkLabel( + top_bar, + text="My Profile", + text_color="white", + font=("Helvetica", 16, "bold"), + ) + top_label.pack(side="left", padx=20) + + def go_back(): + switch_func("dashboard") + + back_button = ctk.CTkButton( + top_bar, + text="Back", + fg_color="white", + text_color="black", + command=go_back, + width=60, + height=30, + ) + back_button.pack(side="right", padx=20, pady=5) + + # ----------------- MAIN SECTION (Sidebar + Content) ----------------- + main_section = ctk.CTkFrame(frame, fg_color="transparent") + main_section.pack(fill="both", expand=True) + + # ----------------- LEFT SIDEBAR ----------------- + + sidebar_frame = ctk.CTkFrame(main_section, width=200, fg_color="#2b2b2b") + sidebar_frame.pack(side="left", fill="y") + + sidebar_title = ctk.CTkLabel( + sidebar_frame, + text="Menu", + font=("Helvetica", 14, "bold"), + text_color="white" + ) + sidebar_title.pack(pady=(10, 5)) + + nav_dashboard = ctk.CTkButton( + sidebar_frame, + text="Dashboard", + fg_color="#2b2b2b", + text_color="white", + hover_color="#3b3b3b", + command=go_back + ) + nav_dashboard.pack(fill="x", padx=10, pady=5) + + nav_profile = ctk.CTkButton( + sidebar_frame, + text="My Profile", + fg_color="#3b3b3b", # Active/selected state + text_color="white", + hover_color="#3b3b3b", + state="disabled" + ) + nav_profile.pack(fill="x", padx=10, pady=5) + + nav_orders = ctk.CTkButton( + sidebar_frame, + text="My Orders", + fg_color="#2b2b2b", + text_color="white", + hover_color="#3b3b3b", + command=lambda: switch_func("user_orders") + ) + nav_orders.pack(fill="x", padx=10, pady=5) + + nav_address = ctk.CTkButton( + sidebar_frame, + text="Address Book", + fg_color="#2b2b2b", + text_color="white", + hover_color="#3b3b3b", + command=lambda: messagebox.showinfo("Info", "Address Book clicked!") + ) + nav_address.pack(fill="x", padx=10, pady=5) + + # ----------------- RIGHT CONTENT (User Details) ----------------- + content_frame = ctk.CTkFrame(main_section, fg_color="transparent") + content_frame.pack(side="left", fill="both", expand=True, padx=20, pady=20) + + # Title / Subtitle + title_label = ctk.CTkLabel( + content_frame, + text="My Profile", + font=("Helvetica", 18, "bold"), + text_color="white" + ) + title_label.pack(anchor="w", pady=(0, 5)) + + subtitle_label = ctk.CTkLabel( + content_frame, + text="Manage your profile information to keep your account secure", + font=("Helvetica", 12), + text_color="#cccccc" + ) + subtitle_label.pack(anchor="w", pady=(0, 15)) + + # -- Split the right content into two frames: Left form & Right picture -- + right_main = ctk.CTkFrame(content_frame, fg_color="transparent") + right_main.pack(fill="both", expand=True) + + # LEFT FORM + form_frame = ctk.CTkFrame(right_main, fg_color="transparent") + form_frame.pack(side="left", fill="both", expand=True, padx=(0, 20)) + + # Username + username_label = ctk.CTkLabel(form_frame, text="Username", font=("Helvetica", 12), text_color="white") + username_label.pack(anchor="w") + username_entry = ctk.CTkEntry(form_frame, placeholder_text="Enter your username...") + username_entry.pack(anchor="w", pady=(0, 10)) + + # Name + name_label = ctk.CTkLabel(form_frame, text="Name", font=("Helvetica", 12), text_color="white") + name_label.pack(anchor="w") + name_entry = ctk.CTkEntry(form_frame, placeholder_text="Enter your name...") + name_entry.pack(anchor="w", pady=(0, 10)) + + # Email + email_label = ctk.CTkLabel(form_frame, text="Email", font=("Helvetica", 12), text_color="white") + email_label.pack(anchor="w") + email_entry = ctk.CTkEntry(form_frame, placeholder_text="Enter your email...") + email_entry.pack(anchor="w", pady=(0, 10)) + + # Phone + phone_label = ctk.CTkLabel(form_frame, text="Phone", font=("Helvetica", 12), text_color="white") + phone_label.pack(anchor="w") + phone_entry = ctk.CTkEntry(form_frame, placeholder_text="Enter your phone...") + phone_entry.pack(anchor="w", pady=(0, 10)) + + # Gender + gender_label = ctk.CTkLabel(form_frame, text="Gender", font=("Helvetica", 12), text_color="white") + gender_label.pack(anchor="w", pady=(10, 0)) + + gender_var = ctk.StringVar(value="Male") # Default + gender_frame = ctk.CTkFrame(form_frame, fg_color="transparent") + gender_frame.pack(anchor="w", pady=5) + + male_radio = ctk.CTkRadioButton(gender_frame, text="Male", variable=gender_var, value="Male", text_color="white") + female_radio = ctk.CTkRadioButton(gender_frame, text="Female", variable=gender_var, value="Female", text_color="white") + other_radio = ctk.CTkRadioButton(gender_frame, text="Other", variable=gender_var, value="Other", text_color="white") + male_radio.pack(side="left", padx=5) + female_radio.pack(side="left", padx=5) + other_radio.pack(side="left", padx=5) + + # Birthday + birthday_label = ctk.CTkLabel(form_frame, text="Date of Birth", font=("Helvetica", 12), text_color="white") + birthday_label.pack(anchor="w", pady=(10, 0)) + birthday_entry = ctk.CTkEntry(form_frame, placeholder_text="dd/mm/yyyy") + birthday_entry.pack(anchor="w", pady=(0, 10)) + + # Save Button + def save_profile(): + """ + Example: Send a request to update user details. + Adjust the endpoint and payload for your backend. + """ + headers = {"Authorization": f"Bearer {token}"} + payload = { + "username": username_entry.get().strip(), + "name": name_entry.get().strip(), + "email": email_entry.get().strip(), + "phone": phone_entry.get().strip(), + "gender": gender_var.get(), + "birthday": birthday_entry.get().strip() + } + try: + resp = requests.put(f"{API_URL}/user/update", headers=headers, json=payload) + if resp.status_code == 200: + messagebox.showinfo("Success", "Profile updated successfully!") + else: + messagebox.showerror("Error", "Unable to update profile.") + except Exception as e: + messagebox.showerror("Error", f"Request error: {e}") + + save_button = ctk.CTkButton( + form_frame, + text="Save", + fg_color=SHOPPING, # #00c1ff + text_color="white", + command=save_profile, + width=80 + ) + save_button.pack(anchor="w", pady=(20, 10)) + + # RIGHT PICTURE SECTION + pic_frame = ctk.CTkFrame(right_main, fg_color="transparent") + pic_frame.pack(side="left", fill="both", expand=True) + + pic_label = ctk.CTkLabel(pic_frame, text="Profile Picture", font=("Helvetica", 12, "bold"), text_color="white") + pic_label.pack(anchor="w", pady=(0, 10)) + + photo_label = ctk.CTkLabel(pic_frame, text="No image", text_color="white") + photo_label.pack(anchor="w", pady=(0, 10)) + + def choose_photo(): + """ + Let the user pick a photo from their files. + Then optionally upload it or just display it. + """ + file_path = filedialog.askopenfilename( + title="Choose a Profile Picture", + filetypes=[("Image Files", "*.png *.jpg *.jpeg *.gif")] + ) + if file_path: + # Display chosen image + try: + pil_img = Image.open(file_path).resize((100, 100)) + tk_img = ImageTk.PhotoImage(pil_img) + photo_label.configure(image=tk_img, text="") + photo_label.image = tk_img + + # Optionally, upload the file to your server: + # headers = {"Authorization": f"Bearer {token}"} + # with open(file_path, 'rb') as f: + # requests.post(f'{API_URL}/user/upload_photo', files={'file': f}, headers=headers) + except Exception as e: + print(f"Image load error: {e}") + messagebox.showerror("Error", "Cannot load the image.") + + change_photo_button = ctk.CTkButton( + pic_frame, + text="Choose Image", + fg_color="white", + text_color="black", + command=choose_photo, + width=80 + ) + change_photo_button.pack(anchor="w") + + # Fetch existing user info (if needed) + def fetch_user_info(): + """ + Fetch the user's existing data to populate the form fields. + """ + headers = {"Authorization": f"Bearer {token}"} + try: + resp = requests.get(f"{API_URL}/user/profile", headers=headers) + if resp.status_code == 200: + data = resp.json() + username_entry.delete(0, "end") + username_entry.insert(0, data.get("username", "")) + name_entry.delete(0, "end") + name_entry.insert(0, data.get("name", "")) + email_entry.delete(0, "end") + email_entry.insert(0, data.get("email", "")) + phone_entry.delete(0, "end") + phone_entry.insert(0, data.get("phone", "")) + gender_var.set(data.get("gender", "Male")) + birthday_entry.delete(0, "end") + birthday_entry.insert(0, data.get("birthday", "")) + + # If there's a profile picture URL, load it + pic_url = data.get("photo_url") + if pic_url: + try: + resp_pic = requests.get(pic_url) + if resp_pic.status_code == 200: + pil_img = Image.open(io.BytesIO(resp_pic.content)).resize((100, 100)) + tk_img = ImageTk.PhotoImage(pil_img) + photo_label.configure(image=tk_img, text="") + photo_label.image = tk_img + except Exception as e: + print(f"Profile picture load error: {e}") + else: + messagebox.showerror("Error", "Unable to retrieve user information.") + except Exception as e: + messagebox.showerror("Error", f"Request error: {e}") + + fetch_user_info() + + return frame diff --git a/app/frontend/components/user_orders.py b/app/frontend/components/user_orders.py new file mode 100644 index 0000000..b71b34f --- /dev/null +++ b/app/frontend/components/user_orders.py @@ -0,0 +1,228 @@ +import customtkinter as ctk +import requests +from PIL import Image, ImageTk +from tkinter import messagebox +import io + +SHOPPING = "#00c1ff" + +def user_orders_frame(parent, switch_func, API_URL, token): + """ + A two-column user orders page that displays the products the user has purchased + along with associated shop details. The layout and color scheme match your dashboard. + """ + + # Main container frame with transparent background + frame = ctk.CTkFrame(parent, fg_color="transparent") + + # ----------------- TOP BAR ----------------- + top_bar = ctk.CTkFrame(frame, fg_color=SHOPPING, height=40) + top_bar.pack(fill="x", side="top") + + top_label = ctk.CTkLabel( + top_bar, + text="My Orders", + text_color="white", + font=("Helvetica", 16, "bold") + ) + top_label.pack(side="left", padx=20) + + def go_back(): + switch_func("dashboard") + + back_button = ctk.CTkButton( + top_bar, + text="Back", + fg_color="white", + text_color="black", + command=go_back, + width=60, + height=30 + ) + back_button.pack(side="right", padx=20, pady=5) + + # ----------------- MAIN SECTION (Sidebar + Content) ----------------- + main_section = ctk.CTkFrame(frame, fg_color="transparent") + main_section.pack(fill="both", expand=True) + + # ----------------- LEFT SIDEBAR ----------------- + sidebar_frame = ctk.CTkFrame(main_section, width=200, fg_color="#2b2b2b") + sidebar_frame.pack(side="left", fill="y") + + sidebar_title = ctk.CTkLabel( + sidebar_frame, + text="Menu", + font=("Helvetica", 14, "bold"), + text_color="white" + ) + sidebar_title.pack(pady=(10, 5)) + + def open_profile(): + switch_func("user_details") + + nav_dashboard = ctk.CTkButton( + sidebar_frame, + text="Dashboard", + fg_color="#2b2b2b", + text_color="white", + hover_color="#3b3b3b", + command=go_back + ) + nav_dashboard.pack(fill="x", padx=10, pady=5) + + nav_profile = ctk.CTkButton( + sidebar_frame, + text="My Profile", + fg_color="#2b2b2b", + text_color="white", + hover_color="#3b3b3b", + command=open_profile + ) + nav_profile.pack(fill="x", padx=10, pady=5) + + nav_orders = ctk.CTkButton( + sidebar_frame, + text="My Orders", + fg_color="#3b3b3b", # Active/selected state + text_color="white", + hover_color="#3b3b3b", + state="disabled" + ) + nav_orders.pack(fill="x", padx=10, pady=5) + + # ----------------- RIGHT CONTENT (Orders List) ----------------- + content_frame = ctk.CTkFrame(main_section, fg_color="transparent") + content_frame.pack(side="left", fill="both", expand=True, padx=20, pady=20) + + main_title_label = ctk.CTkLabel( + content_frame, + text="Your Orders", + font=("Helvetica", 18, "bold"), + text_color="white" + ) + main_title_label.pack(anchor="w", pady=(0, 5)) + + subtitle_label = ctk.CTkLabel( + content_frame, + text="Review the products you have purchased.", + font=("Helvetica", 12), + text_color="#cccccc" + ) + subtitle_label.pack(anchor="w", pady=(0, 15)) + + # A frame to hold the list of orders + orders_list_frame = ctk.CTkFrame(content_frame, fg_color="transparent") + orders_list_frame.pack(fill="both", expand=True) + + # ----------- Functions to fetch and display orders ----------- + def fetch_orders(): + """ + Fetch the list of user orders from the API. + """ + headers = {"Authorization": f"Bearer {token}"} + try: + resp = requests.get(f"{API_URL}/orders/list", headers=headers) + if resp.status_code == 200: + orders = resp.json() # Expect a list of order dicts + display_orders(orders, orders_list_frame) + else: + messagebox.showerror("Error", "Failed to fetch orders.") + except Exception as ex: + messagebox.showerror("Error", f"Request error: {ex}") + + def display_orders(orders, container): + """ + Display each order with product and shop details. + """ + # Clear previous content + for widget in container.winfo_children(): + widget.destroy() + + if not orders: + ctk.CTkLabel(container, text="No orders found.", text_color="white").pack(pady=10) + return + + for order in orders: + # Assume each order dict includes a 'product' dict and an 'order_date' + product = order.get("product", {}) + order_date = order.get("order_date", "Unknown Date") + + order_frame = ctk.CTkFrame(container, corner_radius=5, fg_color="#2b2b2b") + order_frame.pack(fill="x", padx=5, pady=5) + + # Left: Product image + image_label = ctk.CTkLabel(order_frame, text="") + image_label.pack(side="left", padx=5, pady=5) + if product.get("images"): + try: + img_url = product["images"][0]["image_url"] + img_resp = requests.get(img_url) + if img_resp.status_code == 200: + pil_img = Image.open(io.BytesIO(img_resp.content)).resize((60, 60)) + tk_img = ImageTk.PhotoImage(pil_img) + image_label.configure(image=tk_img, text="") + image_label.image = tk_img + except Exception as ex: + print(f"Product image error: {ex}") + + # Right: Order details and shop info + info_frame = ctk.CTkFrame(order_frame, fg_color="transparent") + info_frame.pack(side="left", fill="both", expand=True, padx=10) + + # Product Name + ctk.CTkLabel( + info_frame, + text=product.get("name", "No Name"), + font=("Helvetica", 13, "bold"), + text_color="white" + ).pack(anchor="w") + + # Price (and order date) + price = product.get("price", 0.0) + ctk.CTkLabel( + info_frame, + text=f"Price: {price:.2f}", + text_color="#cccccc" + ).pack(anchor="w") + ctk.CTkLabel( + info_frame, + text=f"Ordered on: {order_date}", + text_color="#cccccc" + ).pack(anchor="w") + + # Shop Name + shop_id = product.get("shop_id") + shop_name_label = ctk.CTkLabel(info_frame, text="Shop: Loading...", text_color="#cccccc") + shop_name_label.pack(anchor="w") + + def fetch_shop_and_update_label(sid, label_widget): + headers = {"Authorization": f"Bearer {token}"} + try: + sresp = requests.get(f"{API_URL}/shop/get/{sid}", headers=headers) + if sresp.status_code == 200: + shop_data = sresp.json() + label_widget.configure(text=f"Shop: {shop_data.get('name', 'No Shop Name')}") + else: + label_widget.configure(text="Shop: Not found") + except Exception: + label_widget.configure(text="Shop: Error fetching") + + fetch_shop_and_update_label(shop_id, shop_name_label) + + # "View Order" button (placeholder action) + def view_order(): + messagebox.showinfo("Order Details", f"View details for order of {product.get('name')}") + + view_button = ctk.CTkButton( + info_frame, + text="View Order", + fg_color=SHOPPING, + text_color="white", + command=view_order + ) + view_button.pack(anchor="e", pady=(5, 0)) + + # Fetch orders when the frame loads + fetch_orders() + + return frame diff --git a/app/frontend/main.py b/app/frontend/main.py index 6773394..65b8f0a 100644 --- a/app/frontend/main.py +++ b/app/frontend/main.py @@ -5,7 +5,9 @@ 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 -from components.dashboard import dashboard_frame # import the dashboard +from components.dashboard import dashboard_frame +from components.user_details import user_details_frame +from components.user_orders import user_orders_frame API_URL = "http://127.0.0.1:8000" access_token = None @@ -37,6 +39,8 @@ 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) dashboard = dashboard_frame(root, switch_frame, API_URL, access_token) +user_details = user_details_frame(root, switch_frame, API_URL, access_token) +user_orders = user_orders_frame(root, switch_frame, API_URL, access_token) frames = { "login": login, @@ -46,6 +50,8 @@ frames = { "category": category, "view_shop": view_shop, "dashboard": dashboard, + "user_details": user_details, + "user_orders": user_orders, } for frame in frames.values(): diff --git a/requirements.txt b/requirements.txt index 2383f12bc661c32a3b8eac0fcf9146bd60a0f3fe..1c5bb150f1acbbb5b29d84ddfede66a9ed8b7204 100644 GIT binary patch literal 2206 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_5irbeMzvoX(KS zfE+TJP?bq=mqKiXr~~-~WCqAJps<731q#<xhE#?;h8%EcfK-6u5EM@k6&Va5_kqMg zHXB029h9yz7)lsQ7z!AY!LgVMwiTogqz>Xjm<mvMfMOewuFV+q7)-#vfT^hf>j&vI z0;g<<?U@Xqct`}xLEHuLQzk<RLoov=tn(N^{wre0X8@%DkUK#>1Yw9uFK}AOVn}2# zLNY~<!4zyeC<PZWfYL%fLpehpgDwNeWL<E)gUkZi333y}EMI8KDP<^N2xdrRNMnHb z8m0=87IPU=7)lw67;?b&fpmjH1twR?P|Q%ski!6qS5T_TV}PVUP)>l@i=5sp!MPY# z27uILF%-j7BgkGua5(`=L7+G(U`S*D<+ntJbg;kE!EP`D#~Q>1AQi<7#o#mrNhu)L zfzmW2K0qlm2b?p@!FoZl4=E=gVE{@;$qXP<Af*9vI)~X%35^+0nUf4Q1r#3;mqPM3 z$P`HILDc9nq%u@6lz?+NdO3*Di`{%(XsHA;36vTjsSKnGl$#;x2;IJTlvE2+YXmOs zAT|UrR5GM9<bv}BD4l^)0!WP^11LTqCW6#>F@!UOp!p9W>&sBd5X=zB-~(0x3S|g} z#1o=i(Pc;h*8`wjSO!-MNjI2ka~Vn*a=@iIBn}W|7f1)FWC!U1xh|5yk--;gJ1D<F z<Uz7U45<u-45i?j2HAhc;9A2NoHL3TK;=mW)IFe-2J$;3jlkqUF_q5%%Gsb27vT~^ z29SD4SQay6Fr+eoQWU66PG<nsBe_shLAelC4iq!wfomvG9R;c5K%oUP2NI%?kaT3o zVMqp>m<z25K=A_dnK1*TUV-GUT!wsb`HskgAT>q|5Wj(H3Q%nZDf2+JFDQmVDohw4 zAq5I0SZxeR;UHN<a5)X}7ep4~r+8@U1;t=7xQ+(Z9UwCyB`wH}5M8AV$oUskFF|qz zByT}V4N#q##9#~#Balu|7{KJqz_9`<2O#wk$W_MR+znAt4sK0=LI@O&Apb#f11Ky( bY9aAg&XCHG1TIHFWd*24hp7U&$bbO=_4q;! literal 1045 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!=ox_QDlIO_&n?N$ z%qsyIV+wXaN@7uVN@_`JatTN;$bl(&#RZim8Tom(wnkv*r{*SR=ERpJ=47TMmgE<K zgpKqJxY81fOA-q*LD~!rP4x^7VIsQ8Ibcx(J#((~RER4wlXEhYKspUTLIsr|Cde1* zMX9NIIjP|AFw`^T$}ltp8)%|uz?D%_QjnZqlxk~hs0T6pLu&jE+4nVtz)K?O*; zk%gWiS7u6HA}DYS4Y)E(ic=Ev(o>7_ON&7^ft>4=nU|Gl1XiGD%9RW9c7A!DZf1!t z$Th}#26_fuzKKQIr3Jx>X{jJ}pfJl#DJ{wYSz)MW#FblFT$rPqoS&DMnp~1!qzels zOFfVU`9)d9pg=R$Gc?dMH03HtOwLYB&&*4=wKX!)GvX>pEG`BIf}x(do(We$W=>9i zxvj0Cp`M|h0arm~aseo+A;D|JRZy9dm{*dS4067qfu0#$KsU9bq$m-Vz>GkaAyn%Y zr<Rmt=B0yu4GI*PG4YUqG}1FN*E8Y@s7%jI%_{+`GPKk)<O-<t3J-x=1>*Tu1_%0p z!W|SR5O3<H<d>x8m4O5e^bEP6g1MzRC7A_@Mc~w9pl4*j6;K)J=<8!^Yo-SZzoOK_ z($r#zO~yug##}|2$r-k`hQ@l9dM03wE+|T~Gr<NJ8tWNx6=$U8<YeZhXC&r=EHl+J z;VRC{OiKf0AwxZ5JwvYGKp)4P<c!o@a56H`Gq&I=F3iczPe}zwm4Tj-F;{U(Vo^?N zNl7Xw@=VP147fm9vIxX8G}SW#@iOz$<5Me2QuB&4^Ye;9`O^@TB}*aEXbK8h1Fq7d zoSe)gV^G)_>ltvBmSraA7v+JBF$Sg4^2CzljI_)gP-X|EQWLK7)THA4<m^<CpAAj* G47dR3XFVtY -- GitLab