diff --git a/database/db.py b/database/db.py index a4c876b..12a5097 100644 --- a/database/db.py +++ b/database/db.py @@ -55,6 +55,33 @@ def create_tables(): FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE ) """) + + # Tạo bảng settings với thêm cột type + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT, + type TEXT DEFAULT 'text', -- NEW: loại giá trị (text, boolean, number) + created_at INTEGER DEFAULT (strftime('%s','now')), + updated_at INTEGER DEFAULT (strftime('%s','now')) + ) + ''') + + # --- Default settings với type --- + default_settings = [ + ("AUTO_LISTING", "true", "boolean"), # Bật/tắt tự động listing + ("MAX_CONCURRENT_LISTING", "2", "number"), # Số lượng listing tối đa chạy song song + ("LISTING_INTERVAL_SECONDS", "5", "number"), # Thời gian nghỉ giữa các listing + ("MAX_CONCURRENT_LISTING", "2", "number"), # Số lượng worker chạy đồng thời + ] + + for key, value, typ in default_settings: + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value, type) VALUES (?, ?, ?)", + (key, value, typ) + ) + conn.commit() diff --git a/database/models/listed.py b/database/models/listed.py index f2f8dfb..bc6555a 100644 --- a/database/models/listed.py +++ b/database/models/listed.py @@ -56,11 +56,6 @@ class Listed: @staticmethod def get_paginated(offset, limit, filters=None, sort_by="id", sort_order="ASC"): - """ - Trả về: - - danh sách dict: mỗi dict gồm id, sku, product_name, account_email, listed_at, condition, status, images - - total_count: tổng số bản ghi - """ filters = filters or {} params = [] @@ -79,41 +74,65 @@ class Listed: JOIN accounts a ON l.account_id = a.id """ - # --- Thêm filter nếu có --- where_clauses = [] - if "status" in filters: - where_clauses.append("l.status = ?") - params.append(filters["status"]) - if "account_id" in filters: + + # --- Filters --- + if "status" in filters and filters["status"]: + status_val = filters["status"] + if isinstance(status_val, list): + placeholders = ",".join(["?"] * len(status_val)) + where_clauses.append(f"l.status IN ({placeholders})") + params.extend(status_val) + else: + where_clauses.append("l.status = ?") + params.append(status_val) + + if "account_id" in filters and filters["account_id"]: where_clauses.append("l.account_id = ?") params.append(filters["account_id"]) + + if "sku" in filters and filters["sku"].strip(): + where_clauses.append("p.sku LIKE ?") + params.append(f"%{filters['sku'].strip()}%") + + if "product_name" in filters and filters["product_name"].strip(): + where_clauses.append("p.name LIKE ?") + params.append(f"%{filters['product_name'].strip()}%") + + if "account_email" in filters and filters["account_email"].strip(): + where_clauses.append("a.email LIKE ?") + params.append(f"%{filters['account_email'].strip()}%") + if where_clauses: base_query += " WHERE " + " AND ".join(where_clauses) - # --- Đếm tổng --- - total_query = f"SELECT COUNT(*) FROM ({base_query})" + # --- Count --- + total_query = f"SELECT COUNT(*) FROM ({base_query}) AS total" conn = get_connection() cur = conn.cursor() cur.execute(total_query, params) total_count = cur.fetchone()[0] - # --- Thêm sort + limit + offset --- + # --- Sort + Paginate --- sort_column_map = { "id": "l.id", + "sku": "p.sku", "product_name": "p.name", - "listed_at": "l.listed_at" + "account_email": "a.email", + "listed_at": "l.listed_at", + "status": "l.status" } sort_col = sort_column_map.get(sort_by, "l.id") + sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC" + base_query += f" ORDER BY {sort_col} {sort_order} LIMIT ? OFFSET ?" params.extend([limit, offset]) cur.execute(base_query, params) rows = cur.fetchall() - # Chuyển row thành dict - items = [] - for r in rows: - items.append({ + items = [ + { "id": r[0], "sku": r[1], "product_name": r[2], @@ -121,12 +140,13 @@ class Listed: "listed_at": r[4], "status": r[5], "condition": r[6], - "images": r[7] # giả sử lưu dạng JSON string - }) + "images": r[7] + } + for r in rows + ] + conn.close() return items, total_count - - @staticmethod def bulk_create(items): @@ -207,3 +227,21 @@ class Listed: conn.commit() conn.close() return deleted_count + + @staticmethod + def count_all(): + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM listed") + total = cursor.fetchone()[0] + conn.close() + return total + + @staticmethod + def count_by_status(status): + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM listed WHERE status=?", (status,)) + count = cursor.fetchone()[0] + conn.close() + return count diff --git a/database/models/setting.py b/database/models/setting.py new file mode 100644 index 0000000..af4063e --- /dev/null +++ b/database/models/setting.py @@ -0,0 +1,162 @@ +# database/models/setting.py +import sqlite3 +import time +from database.db import get_connection + +class Setting: + + # --- Constants for setting keys --- + AUTO_LISTING = "AUTO_LISTING" + MAX_CONCURRENT_LISTING = "MAX_CONCURRENT_LISTING" + LISTING_INTERVAL_SECONDS = "LISTING_INTERVAL_SECONDS" + MAX_CONCURRENT_LISTING = "MAX_CONCURRENT_LISTING" + + + @staticmethod + def all(): + conn = get_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("SELECT * FROM settings") + rows = cur.fetchall() + conn.close() + return [dict(row) for row in rows] + + @staticmethod + def get_by_id(setting_id): + conn = get_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("SELECT * FROM settings WHERE id = ?", (setting_id,)) + row = cur.fetchone() + conn.close() + return dict(row) if row else None + + @staticmethod + def get_by_key(key): + conn = get_connection() + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute( + "SELECT * FROM settings WHERE key = ?", + (key,) + ) + row = cur.fetchone() + conn.close() + if row: + return dict(row) + return None + + @staticmethod + def create(key, value): + existing = Setting.get_by_key(key) + if existing: + return existing + + created_at = updated_at = int(time.time()) + conn = get_connection() + cur = conn.cursor() + cur.execute( + "INSERT INTO settings (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)", + (key, value, created_at, updated_at) + ) + conn.commit() + conn.close() + return Setting.get_by_key(key) + + + @staticmethod + def delete(setting_id): + conn = get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM settings WHERE id = ?", (setting_id,)) + conn.commit() + conn.close() + + @staticmethod + def get_paginated(offset, limit, filters=None, sort_by="id", sort_order="ASC"): + filters = filters or {} + params = [] + + # Lấy luôn type + base_query = "SELECT id, key, value, type, created_at, updated_at FROM settings" + where_clauses = [] + + # --- Filters --- + if "key" in filters and filters["key"].strip(): + where_clauses.append("key LIKE ?") + params.append(f"%{filters['key'].strip()}%") + + if "value" in filters and filters["value"].strip(): + where_clauses.append("value LIKE ?") + params.append(f"%{filters['value'].strip()}%") + + if where_clauses: + base_query += " WHERE " + " AND ".join(where_clauses) + + # --- Count --- + total_query = f"SELECT COUNT(*) FROM ({base_query}) AS total" + conn = get_connection() + cur = conn.cursor() + cur.execute(total_query, params) + total_count = cur.fetchone()[0] + + # --- Sort + Paginate --- + sort_column_map = { + "id": "id", + "key": "key", + "value": "value", + "type": "type", + "created_at": "created_at", + "updated_at": "updated_at" + } + sort_col = sort_column_map.get(sort_by, "id") + sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC" + + base_query += f" ORDER BY {sort_col} {sort_order} LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + cur.execute(base_query, params) + rows = cur.fetchall() + + items = [ + { + "id": r[0], + "key": r[1], + "value": r[2], + "type": r[3], # <-- thêm type + "created_at": r[4], + "updated_at": r[5] + } + for r in rows + ] + + conn.close() + return items, total_count + + + @staticmethod + def get(key: str, default=None): + """Lấy giá trị setting theo key.""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = cursor.fetchone() + conn.close() + return row["value"] if row else default + + @classmethod + def update_value(cls, setting_id, new_value): + s = cls.get_by_id(setting_id) + if not s: + raise ValueError(f"Setting id={setting_id} not found") + s["value"] = new_value + # Lưu vào DB + conn = get_connection() + cursor = conn.cursor() + cursor.execute("UPDATE settings SET value=? WHERE id=?", (new_value, setting_id)) + conn.commit() + conn.close() + return True + + diff --git a/facebook_marketplace.db b/facebook_marketplace.db index 2d54abc..2684a4a 100644 Binary files a/facebook_marketplace.db and b/facebook_marketplace.db differ diff --git a/gui/core/login_handle_dialog.py b/gui/core/login_handle_dialog.py new file mode 100644 index 0000000..46878d0 --- /dev/null +++ b/gui/core/login_handle_dialog.py @@ -0,0 +1,44 @@ +# gui/dialogs/login_handle_dialog.py +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton +from PyQt5.QtCore import QTimer, Qt +from gui.global_signals import global_signals + +class LoginHandleDialog(QDialog): + def __init__(self, account_id=None, duration=10, parent=None): + super().__init__(parent) + self.account_id = account_id + self.duration = duration + self.elapsed = 0 + + self.setWindowTitle(f"Login Handle - Account {self.account_id}") + self.setFixedSize(300, 150) + + layout = QVBoxLayout() + self.label = QLabel(f"Processing account_id={self.account_id}...") + self.label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.label) + + self.progress = QProgressBar() + self.progress.setRange(0, self.duration) + layout.addWidget(self.progress) + + self.btn_cancel = QPushButton("Cancel") + self.btn_cancel.clicked.connect(self.reject) + layout.addWidget(self.btn_cancel) + + self.setLayout(layout) + + self.timer = QTimer(self) + self.timer.timeout.connect(self._update_progress) + self.timer.start(1000) + + def _update_progress(self): + self.elapsed += 1 + self.progress.setValue(self.elapsed) + if self.elapsed >= self.duration: + self.close() + + def closeEvent(self, event): + """Emit signal khi dialog đóng""" + global_signals.dialog_finished.emit(self.account_id) + super().closeEvent(event) diff --git a/gui/global_signals.py b/gui/global_signals.py new file mode 100644 index 0000000..973dc27 --- /dev/null +++ b/gui/global_signals.py @@ -0,0 +1,7 @@ +# gui/global_signals.py +from PyQt5.QtCore import QObject, pyqtSignal + +class GlobalSignals(QObject): + listed_finished = pyqtSignal() + +global_signals = GlobalSignals() diff --git a/gui/main_window.py b/gui/main_window.py index faa4b8e..821a78d 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -1,8 +1,17 @@ -from PyQt5.QtWidgets import QMainWindow, QTabWidget -from gui.tabs.account_tab import AccountTab +# gui/main_window.py + +from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox +from gui.tabs.accounts.account_tab import AccountTab from gui.tabs.products.product_tab import ProductTab from gui.tabs.import_tab import ImportTab from gui.tabs.listeds.listed_tab import ListedTab +from gui.tabs.settings.settings_tab import SettingsTab +from gui.global_signals import global_signals +from gui.core.login_handle_dialog import LoginHandleDialog + +# Task runner +from tasks.task_runner import TaskRunner +from tasks.listed_tasks import process_all_listed # Hàm queue mới, 2 dialog class MainWindow(QMainWindow): def __init__(self): @@ -10,19 +19,21 @@ class MainWindow(QMainWindow): self.setWindowTitle("Facebook Marketplace Manager") self.resize(1200, 600) - # Tạo QTabWidget + # --- Tạo QTabWidget --- self.tabs = QTabWidget() self.account_tab = AccountTab() self.product_tab = ProductTab() self.import_tab = ImportTab() self.listed_tab = ListedTab() + self.setting_tab = SettingsTab() self.tabs.addTab(self.account_tab, "Accounts") self.tabs.addTab(self.product_tab, "Products") self.tabs.addTab(self.listed_tab, "Queue Handle") self.tabs.addTab(self.import_tab, "Import Data") + self.tabs.addTab(self.setting_tab, "Setting") - # Gắn event khi tab thay đổi + # Gắn sự kiện khi tab thay đổi self.tabs.currentChanged.connect(self.on_tab_changed) self.setCentralWidget(self.tabs) @@ -30,6 +41,39 @@ class MainWindow(QMainWindow): # Khi mở app thì chỉ load tab đầu tiên (Accounts) self.on_tab_changed(0) + # --- Kết nối signals --- + global_signals.open_login_dialog.connect(self.show_login_dialog) + global_signals.listed_finished.connect(self.on_listed_task_finished) + + # --- 🔥 Khởi chạy queue background khi app mở --- + self.start_background_tasks() + + # ---------------- Dialog handler ---------------- + def show_login_dialog(self, account_id): + """Mở dialog xử lý account_id, modeless để không block MainWindow""" + dialog = LoginHandleDialog(account_id=account_id) + dialog.show() # modeless + + # ---------------- Background tasks ---------------- + def start_background_tasks(self): + """Khởi động các background task khi app mở.""" + self.task_runner = TaskRunner() + self.task_runner.start() + + # Thêm task xử lý listed pending (dùng hàm queue + 2 dialog cùng lúc) + self.task_runner.add_task(process_all_listed) + print("[App] Background task runner started") + + # ---------------- Listed finished ---------------- + def on_listed_task_finished(self): + """Hiển thị thông báo khi listed task hoàn tất""" + QMessageBox.information( + self, + "Auto Listing", + "All pending listed items have been processed!" + ) + + # ---------------- Tab handling ---------------- def on_tab_changed(self, index): """Chỉ load nội dung tab khi được active.""" tab = self.tabs.widget(index) diff --git a/gui/tabs/account_tab.py b/gui/tabs/accounts/account_tab.py similarity index 67% rename from gui/tabs/account_tab.py rename to gui/tabs/accounts/account_tab.py index 01cfb1e..715410a 100644 --- a/gui/tabs/account_tab.py +++ b/gui/tabs/accounts/account_tab.py @@ -7,65 +7,10 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QFont from PyQt5.QtWidgets import QHeaderView from database.models import Account +from .forms.account_form import AccountForm PAGE_SIZE = 10 -class AccountForm(QDialog): - def __init__(self, parent=None, account=None): - super().__init__(parent) - self.setWindowTitle("Account Form") - self.account = account - self.setMinimumSize(400, 200) # Form to hơn - layout = QVBoxLayout() - - layout.addWidget(QLabel("Email")) - self.email_input = QLineEdit() - self.email_input.setMinimumWidth(250) - layout.addWidget(self.email_input) - - layout.addWidget(QLabel("Password")) - self.password_input = QLineEdit() - layout.addWidget(self.password_input) - - layout.addWidget(QLabel("Status")) - self.active_input = QComboBox() - self.active_input.addItems(["Inactive", "Active"]) - layout.addWidget(self.active_input) - - btn_layout = QHBoxLayout() - self.save_btn = QPushButton("Save") - self.save_btn.setMinimumWidth(80) - self.save_btn.clicked.connect(self.save) - btn_layout.addWidget(self.save_btn) - - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.setMinimumWidth(80) - self.cancel_btn.clicked.connect(self.close) - btn_layout.addWidget(self.cancel_btn) - - layout.addLayout(btn_layout) - self.setLayout(layout) - - # nếu edit account - if account: - self.email_input.setText(account.get("email", "")) - self.password_input.setText(account.get("password", "")) - self.active_input.setCurrentText("Active" if account.get("is_active", 1) == 1 else "Inactive") - - def save(self): - email = self.email_input.text() - password = self.password_input.text() - is_active = 1 if self.active_input.currentText() == "Active" else 0 - try: - if self.account and "id" in self.account: - Account.update(self.account["id"], email, password, is_active) - else: - Account.create(email, password, is_active) - self.accept() - except Exception as e: - QMessageBox.warning(self, "Error", str(e)) - - class AccountTab(QWidget): def __init__(self): super().__init__() diff --git a/gui/tabs/accounts/forms/account_form.py b/gui/tabs/accounts/forms/account_form.py new file mode 100644 index 0000000..858767b --- /dev/null +++ b/gui/tabs/accounts/forms/account_form.py @@ -0,0 +1,85 @@ +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QComboBox, + QPushButton, QMessageBox +) +from database.models.account import Account + +class AccountForm(QDialog): + def __init__(self, parent=None, account=None): + super().__init__(parent) + self.setWindowTitle("Account Form") + self.account = account + self.setMinimumSize(400, 200) + + layout = QVBoxLayout() + + # --- Email --- + layout.addWidget(QLabel("Email")) + self.email_input = QLineEdit() + self.email_input.setMinimumWidth(250) + layout.addWidget(self.email_input) + + # --- Password + toggle button --- + layout.addWidget(QLabel("Password")) + pw_layout = QHBoxLayout() + self.password_input = QLineEdit() + self.password_input.setEchoMode(QLineEdit.Password) + self.password_input.setMinimumWidth(200) + + self.toggle_btn = QPushButton("Show") + self.toggle_btn.setCheckable(True) + self.toggle_btn.setMaximumWidth(80) + self.toggle_btn.clicked.connect(self.toggle_password) + + pw_layout.addWidget(self.password_input) + pw_layout.addWidget(self.toggle_btn) + layout.addLayout(pw_layout) + + # --- Status --- + layout.addWidget(QLabel("Status")) + self.active_input = QComboBox() + self.active_input.addItems(["Inactive", "Active"]) + layout.addWidget(self.active_input) + + # --- Buttons --- + btn_layout = QHBoxLayout() + self.save_btn = QPushButton("Save") + self.save_btn.setMinimumWidth(80) + self.save_btn.clicked.connect(self.save) + btn_layout.addWidget(self.save_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setMinimumWidth(80) + self.cancel_btn.clicked.connect(self.close) + btn_layout.addWidget(self.cancel_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + # --- Nếu edit account --- + if account: + self.email_input.setText(account.get("email", "")) + self.password_input.setText(account.get("password", "")) + self.active_input.setCurrentText("Active" if account.get("is_active", 1) == 1 else "Inactive") + + def toggle_password(self): + """Ẩn/hiện password khi nhấn nút""" + if self.toggle_btn.isChecked(): + self.password_input.setEchoMode(QLineEdit.Normal) + self.toggle_btn.setText("Hide") + else: + self.password_input.setEchoMode(QLineEdit.Password) + self.toggle_btn.setText("Show") + + def save(self): + email = self.email_input.text() + password = self.password_input.text() + is_active = 1 if self.active_input.currentText() == "Active" else 0 + try: + if self.account and "id" in self.account: + Account.update(self.account["id"], email, password, is_active) + else: + Account.create(email, password, is_active) + self.accept() + except Exception as e: + QMessageBox.warning(self, "Error", str(e)) diff --git a/gui/tabs/listeds/dialogs/listed_filter_dialog.py b/gui/tabs/listeds/dialogs/listed_filter_dialog.py new file mode 100644 index 0000000..3b712fe --- /dev/null +++ b/gui/tabs/listeds/dialogs/listed_filter_dialog.py @@ -0,0 +1,64 @@ +from PyQt5.QtWidgets import ( + QDialog, QFormLayout, QLineEdit, QDialogButtonBox, + QVBoxLayout, QWidget, QComboBox +) + +class ListedFilterDialog(QDialog): + STATUS_OPTIONS = ["Any", "pending", "listed"] # ✅ thêm 'Any' để không lọc theo trạng thái + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Filter Listed") + self.setMinimumSize(400, 200) + + main_layout = QVBoxLayout() + form_widget = QWidget() + form_layout = QFormLayout(form_widget) + + # SKU + self.sku_input = QLineEdit() + form_layout.addRow("SKU:", self.sku_input) + + # Product Name + self.product_name_input = QLineEdit() + form_layout.addRow("Product Name:", self.product_name_input) + + # Account Email + self.account_input = QLineEdit() + form_layout.addRow("Account Email:", self.account_input) + + # Status (dropdown) + self.status_input = QComboBox() + self.status_input.addItems(self.STATUS_OPTIONS) + form_layout.addRow("Status:", self.status_input) + + main_layout.addWidget(form_widget) + + # Buttons + self.btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.btn_box.accepted.connect(self.accept) + self.btn_box.rejected.connect(self.reject) + main_layout.addWidget(self.btn_box) + + self.setLayout(main_layout) + + def get_filters(self): + filters = {} + + sku = self.sku_input.text().strip() + if sku: + filters["sku"] = sku + + product_name = self.product_name_input.text().strip() + if product_name: + filters["product_name"] = product_name + + account = self.account_input.text().strip() + if account: + filters["account_email"] = account + + status = self.status_input.currentText() + if status != "Any": + filters["status"] = status + + return filters diff --git a/gui/tabs/listeds/forms/filter_listed_form.py b/gui/tabs/listeds/forms/filter_listed_form.py deleted file mode 100644 index 029fd2f..0000000 --- a/gui/tabs/listeds/forms/filter_listed_form.py +++ /dev/null @@ -1,44 +0,0 @@ -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton - -class ListedFilterForm(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - self.layout = QHBoxLayout() - self.setLayout(self.layout) - - # SKU - self.sku_filter = QLineEdit() - self.sku_filter.setPlaceholderText("SKU") - self.layout.addWidget(self.sku_filter) - - # Product Name - self.name_filter = QLineEdit() - self.name_filter.setPlaceholderText("Product Name") - self.layout.addWidget(self.name_filter) - - # Account Email - self.account_filter = QLineEdit() - self.account_filter.setPlaceholderText("Account") - self.layout.addWidget(self.account_filter) - - # Status - self.status_filter = QLineEdit() - self.status_filter.setPlaceholderText("Status") - self.layout.addWidget(self.status_filter) - - # Apply Filter button - self.apply_filter_btn = QPushButton("Apply Filter") - self.layout.addWidget(self.apply_filter_btn) - - def get_filters(self): - filters = {} - if sku := self.sku_filter.text().strip(): - filters["sku"] = sku - if name := self.name_filter.text().strip(): - filters["product_name"] = name - if account := self.account_filter.text().strip(): - filters["account_email"] = account - if status := self.status_filter.text().strip(): - filters["status"] = status - return filters diff --git a/gui/tabs/listeds/listed_tab.py b/gui/tabs/listeds/listed_tab.py index 6adf0bc..15bbb6f 100644 --- a/gui/tabs/listeds/listed_tab.py +++ b/gui/tabs/listeds/listed_tab.py @@ -3,14 +3,15 @@ import json from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QHBoxLayout, QMenu, QAction, QHeaderView, - QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, QStyle, QStylePainter, QLineEdit + QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, + QStyle, QStylePainter, QLineEdit, QProgressBar ) -from PyQt5.QtCore import Qt, QRect, pyqtSignal +from PyQt5.QtCore import Qt, QRect, pyqtSignal, QTimer from services.core.loading_service import run_with_progress from services.image_service import ImageService from database.models.listed import Listed -from gui.tabs.listed.dialogs.listed_filter_dialog import ListedFilterDialog -from gui.tabs.listed.dialogs.add_listed_dialog import AddListedDialog +from database.models.setting import Setting +from gui.tabs.listeds.dialogs.listed_filter_dialog import ListedFilterDialog PAGE_SIZE = 10 @@ -64,13 +65,27 @@ class ListedTab(QWidget): # Top menu top_layout = QHBoxLayout() - top_layout.addItem(QLabel()) # spacer + top_layout.addStretch() # placeholder stretch + # --- Progress bar --- + self.progress_bar = QProgressBar() + self.progress_bar.setMinimum(0) + self.progress_bar.setMaximum(100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(True) + self.progress_bar.setMinimumWidth(150) + self.progress_bar.setMinimumHeight(25) # thêm chiều cao đủ lớn + self.progress_bar.setAlignment(Qt.AlignCenter) # căn giữa text + top_layout.insertWidget(0, self.progress_bar) + top_layout.addStretch() # đẩy Action button sang phải + + # --- Action button --- self.options_btn = QPushButton("Action") self.options_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.options_btn.setMinimumWidth(50) self.options_btn.setMaximumWidth(120) top_layout.addWidget(self.options_btn) + layout.addLayout(top_layout) # Table @@ -106,6 +121,11 @@ class ListedTab(QWidget): self.setLayout(layout) + # --- Timer để update progress --- + self.listing_timer = QTimer() + self.listing_timer.timeout.connect(self.update_listed_progress) + self.listing_timer.start(1000) # mỗi giây update + # --- Load Data --- def load_data(self, show_progress=True): self.table.clearContents() @@ -204,10 +224,12 @@ class ListedTab(QWidget): header.viewport().update() self.update_options_menu() + self.update_listed_progress() # --- Options Menu --- def update_options_menu(self): menu = QMenu() + # Reload action_reload = QAction("Reload", menu) action_reload.triggered.connect(lambda: self.load_data(show_progress=True)) @@ -224,6 +246,37 @@ class ListedTab(QWidget): action_clear.triggered.connect(self.clear_filters) menu.addAction(action_clear) + # Toggle Auto Listing + auto_setting = Setting.get_by_key(Setting.AUTO_LISTING) + if auto_setting: + setting_id = auto_setting["id"] + auto_listing_val = auto_setting["value"].lower() == "true" + else: + auto_setting = Setting.create(Setting.AUTO_LISTING, "false") + setting_id = auto_setting["id"] + auto_listing_val = False + + action_toggle_auto = QAction( + "Turn Auto Listing OFF" if auto_listing_val else "Turn Auto Listing ON", + menu + ) + + def toggle_auto_listing(): + new_val = "false" if auto_listing_val else "true" + try: + Setting.update_value(setting_id, new_val) + QMessageBox.information( + self, "Auto Listing", + f"AUTO_LISTING set to {new_val.upper()}" + ) + self.update_options_menu() + self.update_listed_progress() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to update AUTO_LISTING: {e}") + + action_toggle_auto.triggered.connect(toggle_auto_listing) + menu.addAction(action_toggle_auto) + # Delete Selected if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked() for i in range(self.table.rowCount())): @@ -233,6 +286,32 @@ class ListedTab(QWidget): self.options_btn.setMenu(menu) + def update_listed_progress(self): + auto_listing_val = Setting.get(Setting.AUTO_LISTING, "false").lower() == "true" + self.progress_bar.setVisible(auto_listing_val) + if not auto_listing_val: + return + + try: + total = Listed.count_all() + listed = Listed.count_by_status("listed") + + # tránh total=0 + max_value = total if total > 0 else 1 + self.progress_bar.setMaximum(max_value) + self.progress_bar.setValue(listed if listed <= max_value else max_value) + + # Bắt buộc bật text + self.progress_bar.setTextVisible(True) + percent = int((listed / max_value) * 100) + self.progress_bar.setFormat(f"{listed}/{total} ({percent}%) listed") + except Exception: + self.progress_bar.setMaximum(1) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(True) + self.progress_bar.setFormat("0/0 listed") + + # --- Filter --- def open_filter_dialog(self): dialog = ListedFilterDialog(self) @@ -273,6 +352,18 @@ class ListedTab(QWidget): if isinstance(cb, QCheckBox): cb.setChecked(checked) + # --- Sorting --- + def handle_header_click(self, index): + if index in self.SORTABLE_COLUMNS: + column = self.SORTABLE_COLUMNS[index] + if self.sort_by == column: + self.sort_order = "DESC" if self.sort_order == "ASC" else "ASC" + else: + self.sort_by = column + self.sort_order = "ASC" + self.current_page = 0 + self.load_data() + # --- Delete --- def delete_selected(self): ids = [int(cb.property("listed_id")) for i in range(self.table.rowCount()) diff --git a/gui/tabs/settings/forms/setting_form.py b/gui/tabs/settings/forms/setting_form.py new file mode 100644 index 0000000..8db8963 --- /dev/null +++ b/gui/tabs/settings/forms/setting_form.py @@ -0,0 +1,71 @@ +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QLineEdit, + QComboBox, QDialogButtonBox, QMessageBox +) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIntValidator + +class SettingForm(QDialog): + def __init__(self, key: str, value: str, type_: str = "text", parent=None): + super().__init__(parent) + self.setWindowTitle(f"Edit Setting '{key}'") + self.key = key + self.old_value = value + self.new_value = None + self.type_ = type_ + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + # --- Label --- + lbl = QLabel(f"Update value for '{key}':") + self.layout.addWidget(lbl) + + # --- Input Widget --- + self.input_widget = None + val_lower = (value or "").lower() + + if self.type_ == "boolean" or val_lower in ("true", "false"): + self.input_widget = QComboBox() + self.input_widget.addItems(["true", "false"]) + self.input_widget.setCurrentText(val_lower if val_lower in ("true", "false") else "true") + else: + self.input_widget = QLineEdit() + self.input_widget.setText(value or "") + if self.type_ == "number": + self.input_widget.setValidator(QIntValidator()) + + self.layout.addWidget(self.input_widget) + + # --- Buttons --- + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.on_accept) + buttons.rejected.connect(self.reject) + self.layout.addWidget(buttons) + + def on_accept(self): + if isinstance(self.input_widget, QLineEdit): + val = self.input_widget.text() + if self.type_ == "number": + if not val.isdigit(): + QMessageBox.warning(self, "Invalid input", "Please enter a valid number.") + return + elif isinstance(self.input_widget, QComboBox): + val = self.input_widget.currentText() + else: + val = self.old_value + + if val == self.old_value: + self.reject() + return + + self.new_value = val + self.accept() + + @staticmethod + def get_new_value(key: str, value: str, type_: str = "text", parent=None): + dialog = SettingForm(key, value, type_=type_, parent=parent) + result = dialog.exec_() + if result == QDialog.Accepted: + return dialog.new_value + return None diff --git a/gui/tabs/settings/settings_tab.py b/gui/tabs/settings/settings_tab.py new file mode 100644 index 0000000..f6ee241 --- /dev/null +++ b/gui/tabs/settings/settings_tab.py @@ -0,0 +1,155 @@ +from functools import partial +import json +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, + QHBoxLayout, QLabel, QPushButton, QMessageBox, QMenu, + QAction, QHeaderView, QSizePolicy, QLineEdit +) +from PyQt5.QtCore import Qt +from services.core.loading_service import run_with_progress +from database.models.setting import Setting +from .forms.setting_form import SettingForm + +PAGE_SIZE = 10 + +# --- Settings Tab --- +class SettingsTab(QWidget): + def __init__(self): + super().__init__() + self.current_page = 0 + self.total_count = 0 + self.total_pages = 0 + self.sort_by = "key" + self.sort_order = "ASC" + + layout = QVBoxLayout() + + # Top Layout + top_layout = QHBoxLayout() + top_layout.addStretch() + layout.addLayout(top_layout) + + # Table + self.table = QTableWidget() + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + layout.addWidget(self.table) + + # Pagination + pag_layout = QHBoxLayout() + self.prev_btn = QPushButton("Previous") + self.prev_btn.clicked.connect(self.prev_page) + pag_layout.addWidget(self.prev_btn) + + self.page_info_label = QLabel("Page 1 / 1 (0 items)") + pag_layout.addWidget(self.page_info_label) + + self.page_input = QLineEdit() + self.page_input.setFixedWidth(50) + self.page_input.setPlaceholderText("Page") + self.page_input.returnPressed.connect(self.go_to_page) + pag_layout.addWidget(self.page_input) + + self.next_btn = QPushButton("Next") + self.next_btn.clicked.connect(self.next_page) + pag_layout.addWidget(self.next_btn) + layout.addLayout(pag_layout) + + self.setLayout(layout) + + # --- Load Data --- + def load_data(self, show_progress=True): + self.table.clearContents() + self.table.setRowCount(0) + + offset = self.current_page * PAGE_SIZE + settings, total_count = Setting.get_paginated( + offset, PAGE_SIZE, sort_by=self.sort_by, sort_order=self.sort_order + ) + + self.total_count = total_count + self.total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) + + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels(["ID", "Key", "Value", "Action"]) + self.table.setRowCount(len(settings)) + + def handler(s, i_row): + setting_id = s.get("id") + key = s.get("key") + value = s.get("value") or "" + type_ = s.get("type") or "text" + + # ID + id_item = QTableWidgetItem(str(setting_id)) + id_item.setFlags(id_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(i_row, 0, id_item) + + # Key + key_item = QTableWidgetItem(key) + key_item.setFlags(key_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(i_row, 1, key_item) + + # Value + value_item = QTableWidgetItem(value) + self.table.setItem(i_row, 2, value_item) + + # Action + btn_menu = QPushButton("Action") + btn_menu.setMaximumWidth(120) + menu = QMenu(btn_menu) + act_edit = menu.addAction("Edit") + act_edit.triggered.connect(partial(self.edit_setting, setting_id, key, i_row, type_)) + btn_menu.setMenu(menu) + self.table.setCellWidget(i_row, 3, btn_menu) + + items_with_index = [(s, i) for i, s in enumerate(settings)] + if show_progress: + run_with_progress(items_with_index, handler=lambda x: handler(*x), message="Loading settings...", parent=self) + else: + for item in items_with_index: + handler(*item) + + # Resize header + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID + header.setSectionResizeMode(1, QHeaderView.Stretch) # Key + header.setSectionResizeMode(2, QHeaderView.Stretch) # Value + header.setSectionResizeMode(3, QHeaderView.Fixed) # Action + self.table.setColumnWidth(3, 120) + + # Pagination + self.prev_btn.setEnabled(self.current_page > 0) + self.next_btn.setEnabled(self.current_page < self.total_pages - 1) + self.page_info_label.setText(f"Page {self.current_page + 1} / {self.total_pages} ({self.total_count} items)") + + # --- Edit setting --- + def edit_setting(self, setting_id, key, row, type_): + old_value = self.table.item(row, 2).text() + new_value = SettingForm.get_new_value(key, old_value, type_=type_, parent=self) + if new_value is not None and new_value != old_value: + try: + Setting.update_value(setting_id, new_value) + self.table.item(row, 2).setText(new_value) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to update setting: {e}") + + # --- Pagination --- + def go_to_page(self): + try: + page = int(self.page_input.text()) - 1 + if 0 <= page < self.total_pages: + self.current_page = page + self.load_data() + except ValueError: + pass + + def next_page(self): + if self.current_page < self.total_pages - 1: + self.current_page += 1 + self.load_data() + + def prev_page(self): + if self.current_page > 0: + self.current_page -= 1 + self.load_data() diff --git a/services/core/log_service.py b/services/core/log_service.py new file mode 100644 index 0000000..b677546 --- /dev/null +++ b/services/core/log_service.py @@ -0,0 +1,50 @@ +import os +import logging +from datetime import datetime + +class LogService: + def __init__(self, log_dir="logs"): + self.log_dir = log_dir + os.makedirs(self.log_dir, exist_ok=True) + + log_filename = os.path.join(self.log_dir, f"{datetime.now().strftime('%Y-%m-%d')}.log") + + # Tạo logger + self.logger = logging.getLogger("TaskLogger") + self.logger.setLevel(logging.INFO) + self.logger.propagate = False + + # Xóa handler cũ để tránh duplicate log khi import nhiều lần + if self.logger.handlers: + self.logger.handlers.clear() + + # Ghi file + file_handler = logging.FileHandler(log_filename, encoding="utf-8") + file_handler.setLevel(logging.INFO) + + # In ra console + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # Format log + formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def info(self, message: str): + self.logger.info(message) + + def error(self, message: str): + self.logger.error(message) + + def warning(self, message: str): + self.logger.warning(message) + +# Singleton instance dùng chung toàn app +log_service = LogService() diff --git a/tasks/listed_tasks.py b/tasks/listed_tasks.py new file mode 100644 index 0000000..78cb325 --- /dev/null +++ b/tasks/listed_tasks.py @@ -0,0 +1,138 @@ +# tasks/listed_tasks.py + +import threading +import queue +from database.db import get_connection +from services.core.log_service import log_service +from gui.global_signals import global_signals + +stop_event = threading.Event() +dialog_semaphore = None # sẽ khởi tạo dựa trên setting + + +def stop_listed_task(): + """Dừng toàn bộ task""" + stop_event.set() + log_service.info("[Task] Stop signal sent") + + +def get_settings(): + """Lấy setting từ DB""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT key, value FROM settings") + rows = cursor.fetchall() + conn.close() + return {row["key"]: row["value"] for row in rows} + + +def get_pending_items(limit=1000): + """Lấy danh sách pending listed items""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute(f""" + SELECT l.id, l.account_id, l.product_id, l.listed_at, l.status + FROM listed l + JOIN accounts a ON l.account_id = a.id + WHERE l.status='pending' + AND a.is_active=1 + ORDER BY l.listed_at ASC + LIMIT {limit} + """) + rows = cursor.fetchall() + conn.close() + return rows + + +def process_item(row): + """Xử lý 1 item: mở dialog trên main thread và update DB""" + listed_id, account_id, product_id, listed_at, status = row + log_service.info(f"[Task] Processing listed_id={listed_id}") + + if stop_event.is_set(): + log_service.info("[Task] Stop signal received, skip item") + return + + # --- Đợi semaphore để giới hạn số dialog --- + dialog_semaphore.acquire() + + # --- Tạo event để chờ dialog xong --- + finished_event = threading.Event() + + # Callback khi dialog xong + def on_dialog_finished(finished_account_id): + if finished_account_id == account_id: + finished_event.set() + global_signals.dialog_finished.disconnect(on_dialog_finished) + dialog_semaphore.release() + + global_signals.dialog_finished.connect(on_dialog_finished) + + # --- Emit signal mở dialog trên main thread --- + global_signals.open_login_dialog.emit(account_id) + + # --- Chờ dialog hoàn tất --- + finished_event.wait() + + # --- Update DB --- + conn = get_connection() + cursor = conn.cursor() + cursor.execute("UPDATE listed SET status='listed' WHERE id=?", (listed_id,)) + conn.commit() + conn.close() + + log_service.info(f"[Task] Finished listed_id={listed_id}") + + +def worker_thread(q: queue.Queue): + while not stop_event.is_set(): + try: + row = q.get(timeout=1) + except queue.Empty: + break + try: + process_item(row) + except Exception as e: + log_service.error(f"[Task] Exception in worker: {e}") + finally: + q.task_done() + + +def process_all_listed(): + """ + Entry point: tạo queue và 2 worker thread (hoặc theo setting) + """ + log_service.info("[Task] Start processing all listed items") + + settings = get_settings() + max_workers = int(settings.get("MAX_CONCURRENT_LISTING", 2)) + global dialog_semaphore + dialog_semaphore = threading.Semaphore(max_workers) + + # --- Lấy pending items --- + items = get_pending_items(limit=1000) + if not items: + log_service.info("[Task] No pending items") + global_signals.listed_finished.emit() + return + + # --- Tạo queue --- + q = queue.Queue() + for row in items: + q.put(row) + + # --- Tạo worker threads --- + threads = [] + for _ in range(max_workers): + t = threading.Thread(target=worker_thread, args=(q,)) + t.start() + threads.append(t) + + # --- Chờ queue xong --- + q.join() + stop_event.set() + for t in threads: + t.join() + + log_service.info("[Task] All listed items processed") + global_signals.listed_finished.emit() diff --git a/tasks/task_runner.py b/tasks/task_runner.py new file mode 100644 index 0000000..c0b3370 --- /dev/null +++ b/tasks/task_runner.py @@ -0,0 +1,29 @@ +import threading +import queue +import time + +class TaskRunner: + def __init__(self): + self.task_queue = queue.Queue() + self.running = False + + def start(self): + self.running = True + worker = threading.Thread(target=self.run, daemon=True) + worker.start() + + def stop(self): + self.running = False + + def add_task(self, func, *args, **kwargs): + """Thêm task vào hàng đợi""" + self.task_queue.put((func, args, kwargs)) + + def run(self): + while self.running: + try: + func, args, kwargs = self.task_queue.get(timeout=1) + func(*args, **kwargs) + self.task_queue.task_done() + except queue.Empty: + continue