first commit

This commit is contained in:
Admin 2025-10-11 11:32:07 +07:00
commit ccd85a03fc
27 changed files with 2139 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Cấu hình môi trường ảo
env/
venv/
ENV/
.venv/
.env/
# Pipenv
Pipfile.lock
# Poetry
poetry.lock
# Cấu hình pytest
.cache
.pytest_cache/
# Cấu hình mypy
.mypy_cache/
# Cấu hình coverage
.coverage
htmlcov/
.coverage.*
# Cấu hình Jupyter Notebook
.ipynb_checkpoints
# File log
*.log
# File SQLite
*.sqlite3
# Cấu hình IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# OS specific
.DS_Store
Thumbs.db

0
README.md Normal file
View File

14
app.py Normal file
View File

@ -0,0 +1,14 @@
import sys
from PyQt5.QtWidgets import QApplication
from gui.main_window import MainWindow
from database.db import create_tables
def main():
create_tables() # tạo bảng nếu chưa tồn tại
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()

0
config.py Normal file
View File

61
database/db.py Normal file
View File

@ -0,0 +1,61 @@
import sqlite3
from pathlib import Path
DB_PATH = Path("facebook_marketplace.db")
def get_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def create_tables():
conn = get_connection()
cursor = conn.cursor()
# Table quản lý tài khoản Facebook
cursor.execute('''
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
is_active INTEGER DEFAULT 1
)
''')
# Table quản lý sản phẩm
cursor.execute("""
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price REAL,
url TEXT,
status INTEGER DEFAULT 0, -- 0 = draft, 1 = published
images TEXT,
created_at INTEGER,
category TEXT DEFAULT NULL,
condition TEXT DEFAULT NULL,
brand TEXT,
description TEXT,
tags TEXT DEFAULT NULL, -- lưu dưới dạng JSON string
sku TEXT UNIQUE,
location TEXT DEFAULT NULL
)
""")
# Table quản lý sản phẩm đã listed bởi account
cursor.execute("""
CREATE TABLE IF NOT EXISTS listed (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
listed_at INTEGER,
status TEXT DEFAULT 'pending', -- 'pending' hoặc 'listed'
UNIQUE(account_id, product_id),
FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE
)
""")
conn.commit()
conn.close()

View File

@ -0,0 +1,2 @@
from .account import Account
from .product import Product

View File

@ -0,0 +1,41 @@
from database.db import get_connection
class Account:
@staticmethod
def all():
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM accounts")
rows = cur.fetchall()
conn.close()
return rows
@staticmethod
def create(email, password, is_active=1):
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO accounts (email, password, is_active) VALUES (?, ?, ?)",
(email, password, is_active)
)
conn.commit()
conn.close()
@staticmethod
def update(account_id, email, password, is_active):
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET email = ?, password = ?, is_active = ? WHERE id = ?",
(email, password, is_active, account_id)
)
conn.commit()
conn.close()
@staticmethod
def delete(account_id):
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
conn.commit()
conn.close()

209
database/models/listed.py Normal file
View File

@ -0,0 +1,209 @@
# database/models/listed.py
import sqlite3
import time
from database.db import get_connection
class Listed:
@staticmethod
def all():
conn = get_connection()
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT * FROM listed")
rows = cur.fetchall()
conn.close()
return [dict(row) for row in rows]
@staticmethod
def get_by_account_product(account_id, product_id):
conn = get_connection()
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
"SELECT * FROM listed WHERE account_id = ? AND product_id = ?",
(account_id, product_id)
)
row = cur.fetchone()
conn.close()
if row:
return dict(row)
return None
@staticmethod
def create(account_id, product_id):
existing = Listed.get_by_account_product(account_id, product_id)
if existing:
return existing
listed_at = int(time.time())
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO listed (account_id, product_id, listed_at) VALUES (?, ?, ?)",
(account_id, product_id, listed_at)
)
conn.commit()
conn.close()
return Listed.get_by_account_product(account_id, product_id)
@staticmethod
def delete(listed_id):
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM listed WHERE id = ?", (listed_id,))
conn.commit()
conn.close()
@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 = []
base_query = """
SELECT
l.id AS id,
p.sku AS sku,
p.name AS product_name,
a.email AS account_email,
l.listed_at AS listed_at,
l.status AS status,
p.condition AS condition,
p.images AS images
FROM listed l
JOIN products p ON l.product_id = p.id
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:
where_clauses.append("l.account_id = ?")
params.append(filters["account_id"])
if where_clauses:
base_query += " WHERE " + " AND ".join(where_clauses)
# --- Đếm tổng ---
total_query = f"SELECT COUNT(*) FROM ({base_query})"
conn = get_connection()
cur = conn.cursor()
cur.execute(total_query, params)
total_count = cur.fetchone()[0]
# --- Thêm sort + limit + offset ---
sort_column_map = {
"id": "l.id",
"product_name": "p.name",
"listed_at": "l.listed_at"
}
sort_col = sort_column_map.get(sort_by, "l.id")
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({
"id": r[0],
"sku": r[1],
"product_name": r[2],
"account_email": r[3],
"listed_at": r[4],
"status": r[5],
"condition": r[6],
"images": r[7] # giả sử lưu dạng JSON string
})
return items, total_count
@staticmethod
def bulk_create(items):
"""
Thêm nhiều bản ghi listed cùng lúc.
items = list of dict, mỗi dict 'account_id' 'product_id'
Nếu đã tồn tại thì bỏ qua, trả về danh sách tất cả bản ghi (mới đã tồn tại)
"""
results = []
conn = get_connection()
cur = conn.cursor()
for item in items:
account_id = item.get("account_id")
product_id = item.get("product_id")
if not account_id or not product_id:
continue
# Kiểm tra trùng
cur.execute(
"SELECT * FROM listed WHERE account_id = ? AND product_id = ?",
(account_id, product_id)
)
row = cur.fetchone()
if row:
results.append(dict(row))
continue
# Insert mới
listed_at = int(time.time())
cur.execute(
"INSERT INTO listed (account_id, product_id, listed_at) VALUES (?, ?, ?)",
(account_id, product_id, listed_at)
)
listed_id = cur.lastrowid
cur.execute("SELECT * FROM listed WHERE id = ?", (listed_id,))
results.append(dict(cur.fetchone()))
conn.commit()
conn.close()
return results
@staticmethod
def bulk_delete(ids=None, items=None):
"""
Xóa nhiều bản ghi listed cùng lúc.
- ids: list các id của listed
- items: list dict {'account_id': ..., 'product_id': ...} để xóa theo cặp
"""
if not ids and not items:
return 0 # không có gì để xóa
conn = get_connection()
cur = conn.cursor()
deleted_count = 0
# Xóa theo id
if ids:
placeholders = ",".join("?" for _ in ids)
cur.execute(f"DELETE FROM listed WHERE id IN ({placeholders})", ids)
deleted_count += cur.rowcount
# Xóa theo account_id + product_id
if items:
for item in items:
account_id = item.get("account_id")
product_id = item.get("product_id")
if not account_id or not product_id:
continue
cur.execute(
"DELETE FROM listed WHERE account_id = ? AND product_id = ?",
(account_id, product_id)
)
deleted_count += cur.rowcount
conn.commit()
conn.close()
return deleted_count

208
database/models/product.py Normal file
View File

@ -0,0 +1,208 @@
# database/models/product.py
import json
import time
import sqlite3
from database.db import get_connection
class Product:
@staticmethod
def all():
"""Lấy toàn bộ danh sách sản phẩm"""
conn = get_connection()
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("SELECT * FROM products")
rows = cur.fetchall()
conn.close()
return [dict(row) for row in rows]
@staticmethod
def get_paginated(offset, limit, filters=None, sort_by="id", sort_order="ASC"):
"""Lấy danh sách sản phẩm có phân trang, lọc, sắp xếp"""
conn = get_connection()
conn.row_factory = sqlite3.Row
cur = conn.cursor()
sql = "SELECT * FROM products WHERE 1=1"
params = []
# Filters
if filters:
if "name" in filters:
sql += " AND name LIKE ?"
params.append(f"%{filters['name']}%")
if "price" in filters:
sql += " AND price = ?"
params.append(filters["price"])
if "created_at" in filters:
sql += " AND DATE(datetime(created_at, 'unixepoch')) = ?"
params.append(filters["created_at"]) # YYYY-MM-DD
if "category" in filters:
sql += " AND category = ?"
params.append(filters["category"])
if "condition" in filters:
sql += " AND condition = ?"
params.append(filters["condition"])
if "brand" in filters:
sql += " AND brand LIKE ?"
params.append(f"%{filters['brand']}%")
if "tags" in filters:
sql += " AND tags LIKE ?"
params.append(f"%{filters['tags']}%")
if "sku" in filters:
sql += " AND sku = ?"
params.append(filters["sku"])
if "location" in filters:
sql += " AND location LIKE ?"
params.append(f"%{filters['location']}%")
# Count total
count_sql = f"SELECT COUNT(*) as total FROM ({sql})"
cur.execute(count_sql, params)
total_count = cur.fetchone()["total"]
# Sorting
allowed_columns = ["id", "name", "price", "created_at", "category", "condition", "brand"]
if sort_by not in allowed_columns:
sort_by = "id"
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
sql += f" ORDER BY {sort_by} {sort_order}"
# Pagination
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
cur.execute(sql, params)
rows = cur.fetchall()
conn.close()
return [dict(row) for row in rows], total_count
@staticmethod
def create(
name,
price,
images=None,
url=None,
status="draft",
category=None,
condition=None,
brand=None,
description=None,
tags=None,
sku=None,
location=None
):
"""Tạo mới sản phẩm"""
images_json = json.dumps(images or [])
tags_json = json.dumps(tags) if tags else None
created_at = int(time.time())
conn = get_connection()
cur = conn.cursor()
cur.execute("""
INSERT INTO products
(name, price, images, url, status, category, condition, brand, description, tags, sku, location, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
name, price, images_json, url, status, category,
condition, brand, description, tags_json, sku, location, created_at
))
conn.commit()
conn.close()
@staticmethod
def update(
product_id,
name,
price,
images=None,
url=None,
status="draft",
category=None,
condition=None,
brand=None,
description=None,
tags=None,
sku=None,
location=None
):
"""Cập nhật sản phẩm"""
images_json = json.dumps(images or [])
tags_json = json.dumps(tags) if tags else None
conn = get_connection()
cur = conn.cursor()
cur.execute("""
UPDATE products
SET name=?, price=?, images=?, url=?, status=?, category=?,
condition=?, brand=?, description=?, tags=?, sku=?, location=?
WHERE id=?
""", (
name, price, images_json, url, status, category,
condition, brand, description, tags_json, sku, location, product_id
))
conn.commit()
conn.close()
@staticmethod
def delete(product_id):
"""Xoá sản phẩm theo ID"""
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM products WHERE id=?", (product_id,))
conn.commit()
conn.close()
@staticmethod
def insert_from_import(data):
"""Thêm sản phẩm khi import từ CSV hoặc API (tái sử dụng hàm create).
Nếu trùng SKU thì trả về bản ghi hiện không lưu mới.
"""
# Parse images từ chuỗi nếu cần
images = data.get("images")
if isinstance(images, str):
try:
# thử parse dạng JSON
images = json.loads(images)
except Exception:
# fallback: tách theo dấu phẩy
images = [img.strip() for img in images.split(",") if img.strip()]
sku = data.get("sku")
if sku:
existing = Product.get_by_sku(sku)
if existing:
return existing # trả về bản ghi đã có
# Nếu chưa tồn tại, tạo mới
return Product.create(
name=data.get("name"),
price=float(data.get("price", 0)),
images=images,
url=data.get("url"),
status=data.get("status", "draft"),
category=data.get("category"),
condition=data.get("condition"),
brand=data.get("brand"),
description=data.get("description"),
tags=data.get("tags"),
sku=sku,
location=data.get("location"),
)
@staticmethod
def get_by_sku(sku):
"""Trả về dict của sản phẩm theo SKU, hoặc None nếu không tồn tại"""
conn = get_connection() # <-- sửa ở đây
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM products WHERE sku = ?", (sku,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None

BIN
facebook_marketplace.db Normal file

Binary file not shown.

42
gui/main_window.py Normal file
View File

@ -0,0 +1,42 @@
from PyQt5.QtWidgets import QMainWindow, QTabWidget
from gui.tabs.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
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Facebook Marketplace Manager")
self.resize(1200, 600)
# Tạo QTabWidget
self.tabs = QTabWidget()
self.account_tab = AccountTab()
self.product_tab = ProductTab()
self.import_tab = ImportTab()
self.listed_tab = ListedTab()
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")
# Gắn event khi tab thay đổi
self.tabs.currentChanged.connect(self.on_tab_changed)
self.setCentralWidget(self.tabs)
# Khi mở app thì chỉ load tab đầu tiên (Accounts)
self.on_tab_changed(0)
def on_tab_changed(self, index):
"""Chỉ load nội dung tab khi được active."""
tab = self.tabs.widget(index)
# Mỗi tab có thể có hàm load_data() riêng
if hasattr(tab, "load_data"):
# Thêm cờ để tránh load lại nhiều lần không cần thiết
if not getattr(tab, "is_loaded", False):
tab.load_data()
tab.is_loaded = True

173
gui/tabs/account_tab.py Normal file
View File

@ -0,0 +1,173 @@
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox,
QMenu, QAction, QSizePolicy
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QHeaderView
from database.models import Account
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__()
self.current_page = 0
layout = QVBoxLayout()
# Add button
self.add_btn = QPushButton("Add Account")
self.add_btn.setMinimumWidth(120)
self.add_btn.clicked.connect(self.add_account)
layout.addWidget(self.add_btn)
# Table
self.table = QTableWidget()
self.table.verticalHeader().setDefaultSectionSize(28) # row gọn
self.table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
layout.addWidget(self.table)
# Pagination
pag_layout = QHBoxLayout()
self.prev_btn = QPushButton("Previous")
self.prev_btn.setMinimumWidth(100)
self.prev_btn.clicked.connect(self.prev_page)
pag_layout.addWidget(self.prev_btn)
self.next_btn = QPushButton("Next")
self.next_btn.setMinimumWidth(100)
self.next_btn.clicked.connect(self.next_page)
pag_layout.addWidget(self.next_btn)
layout.addLayout(pag_layout)
self.setLayout(layout)
self.load_data()
def load_data(self):
accounts = Account.all()
start = self.current_page * PAGE_SIZE
end = start + PAGE_SIZE
page_items = accounts[start:end]
self.table.setRowCount(len(page_items))
self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels(["ID", "Email", "Status", "Actions"])
for i, acc in enumerate(page_items):
# convert sqlite3.Row -> dict
acc_dict = {k: acc[k] for k in acc.keys()}
self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"])))
self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"]))
self.table.setItem(i, 2, QTableWidgetItem("Active" if acc_dict["is_active"] == 1 else "Inactive"))
# nút menu Actions
btn_menu = QPushButton("Actions")
menu = QMenu()
action_edit = QAction("Edit", btn_menu)
action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a))
menu.addAction(action_edit)
action_delete = QAction("Delete", btn_menu)
action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a))
menu.addAction(action_delete)
btn_menu.setMenu(menu)
self.table.setCellWidget(i, 3, btn_menu)
# Phân bổ column width hợp lý
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID
header.setSectionResizeMode(1, QHeaderView.Stretch) # Email stretch
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Status
header.setSectionResizeMode(3, QHeaderView.Fixed) # Actions fixed width
self.table.setColumnWidth(3, 100) # 100px cho Actions
# Enable/disable pagination buttons
self.prev_btn.setEnabled(self.current_page > 0)
self.next_btn.setEnabled(end < len(accounts))
def add_account(self):
form = AccountForm(self)
if form.exec_():
self.current_page = 0
self.load_data()
def edit_account(self, account):
form = AccountForm(self, account)
if form.exec_():
self.load_data()
def delete_account(self, account):
confirm = QMessageBox.question(
self, "Confirm", f"Delete account {account['email']}?",
QMessageBox.Yes | QMessageBox.No
)
if confirm == QMessageBox.Yes:
Account.delete(account["id"])
self.load_data()
def next_page(self):
self.current_page += 1
self.load_data()
def prev_page(self):
self.current_page -= 1
self.load_data()

122
gui/tabs/import_tab.py Normal file
View File

@ -0,0 +1,122 @@
import csv
import time
from services.core.loading_service import run_with_progress
from database.models.product import Product
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QFileDialog,
QTableWidget, QTableWidgetItem, QHBoxLayout, QMessageBox
)
class ImportTab(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
# --- Action buttons ---
btn_layout = QHBoxLayout()
self.btn_import_csv = QPushButton("Import from CSV")
self.btn_import_csv.clicked.connect(self.import_csv)
btn_layout.addWidget(self.btn_import_csv)
self.btn_import_api = QPushButton("Import from API")
self.btn_import_api.clicked.connect(self.import_api)
btn_layout.addWidget(self.btn_import_api)
self.btn_save = QPushButton("Save to Database")
self.btn_save.clicked.connect(self.save_to_db)
self.btn_save.setEnabled(False)
btn_layout.addWidget(self.btn_save)
layout.addLayout(btn_layout)
# --- Table preview ---
self.table = QTableWidget()
layout.addWidget(self.table)
self.setLayout(layout)
self.preview_data = [] # store imported data
def import_csv(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select CSV File", "", "CSV Files (*.csv)")
if not file_path:
return
try:
# đọc CSV chuẩn với DictReader
with open(file_path, newline='', encoding="utf-8-sig") as csvfile:
reader = csv.DictReader(csvfile)
headers = reader.fieldnames
rows = list(reader)
if not headers or not rows:
QMessageBox.warning(self, "Warning", "CSV file is empty or invalid.")
return
self.table.setColumnCount(len(headers))
self.table.setHorizontalHeaderLabels(headers)
# Hiển thị preview (giới hạn 200 dòng để tránh lag)
preview_limit = min(len(rows), 200)
self.table.setRowCount(preview_limit)
for i in range(preview_limit):
for j, header in enumerate(headers):
value = rows[i].get(header, "")
self.table.setItem(i, j, QTableWidgetItem(value))
self.table.resizeColumnsToContents()
self.preview_data = rows
self.btn_save.setEnabled(True)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to read CSV: {e}")
def import_api(self):
QMessageBox.information(self, "Info", "API import feature will be developed later 😉")
def save_to_db(self):
if not self.preview_data:
QMessageBox.warning(self, "Warning", "No data to save.")
return
# Xác nhận trước khi import
reply = QMessageBox.question(
self,
"Confirm Import",
f"Are you sure you want to import {len(self.preview_data)} rows?",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
def handler(item):
try:
# time.sleep(0.05) # có thể bỏ nếu không cần debug progress
Product.insert_from_import(item)
return True
except Exception as e:
print(f"Failed to import row: {e}")
return False
success, fail = run_with_progress(
self.preview_data,
handler=handler,
message="Importing data...",
parent=self
)
QMessageBox.information(
self,
"Import Completed",
f"Successfully imported {success}/{len(self.preview_data)} rows.\nFailed: {fail} rows."
)
# ✅ Clear preview sau khi import xong
self.preview_data.clear()
self.table.clearContents()
self.table.setRowCount(0)
self.table.setColumnCount(0)
self.btn_save.setEnabled(False)

View File

@ -0,0 +1,44 @@
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

View File

@ -0,0 +1,303 @@
from functools import partial
import json
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QHBoxLayout, QMenu, QAction, QHeaderView,
QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, QStyle, QStylePainter, QLineEdit
)
from PyQt5.QtCore import Qt, QRect, pyqtSignal
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
PAGE_SIZE = 10
# --- Header Checkbox ---
class CheckBoxHeader(QHeaderView):
select_all_changed = pyqtSignal(bool)
def __init__(self, orientation, parent=None):
super().__init__(orientation, parent)
self.isOn = False
self.setSectionsClickable(True)
self.sectionPressed.connect(self.handle_section_pressed)
def paintSection(self, painter, rect, logicalIndex):
super().paintSection(painter, rect, logicalIndex)
if logicalIndex == 0:
option = QStyleOptionButton()
size = 20
x = rect.x() + (rect.width() - size) // 2
y = rect.y() + (rect.height() - size) // 2
option.rect = QRect(x, y, size, size)
option.state = QStyle.State_Enabled | (QStyle.State_On if self.isOn else QStyle.State_Off)
painter2 = QStylePainter(self.viewport())
painter2.drawControl(QStyle.CE_CheckBox, option)
def handle_section_pressed(self, logicalIndex):
if logicalIndex == 0:
self.isOn = not self.isOn
self.select_all_changed.emit(self.isOn)
self.viewport().update()
# --- ListedTab ---
class ListedTab(QWidget):
SORTABLE_COLUMNS = {
1: "id",
3: "product_name",
5: "listed_at"
}
def __init__(self):
super().__init__()
self.current_page = 0
self.total_count = 0
self.total_pages = 0
self.filters = {}
self.sort_by = "id"
self.sort_order = "ASC"
layout = QVBoxLayout()
# Top menu
top_layout = QHBoxLayout()
top_layout.addItem(QLabel()) # spacer
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
self.table = QTableWidget()
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
header = CheckBoxHeader(Qt.Horizontal, self.table)
self.table.setHorizontalHeader(header)
header.select_all_changed.connect(self.select_all_rows)
header.sectionClicked.connect(self.handle_header_click)
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
page_items, total_count = Listed.get_paginated(
offset, PAGE_SIZE, self.filters,
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(9)
columns = ["", "Image", "SKU", "Product Name", "Account", "Listed At", "Condition", "Status", "Actions"]
self.table.setHorizontalHeaderLabels(columns)
self.table.setRowCount(len(page_items))
def handler(item, i_row):
listed_id = item.get("id")
# Checkbox
cb = QCheckBox()
cb.setProperty("listed_id", listed_id)
self.table.setCellWidget(i_row, 0, cb)
cb.stateChanged.connect(self.update_options_menu)
# Image
images = json.loads(item.get("images") or "[]")
if images:
pixmap = ImageService.get_display_pixmap(images[0], size=60)
if pixmap:
lbl = QLabel()
lbl.setPixmap(pixmap)
lbl.setAlignment(Qt.AlignCenter)
self.table.setCellWidget(i_row, 1, lbl)
else:
self.table.setItem(i_row, 1, QTableWidgetItem("None"))
else:
self.table.setItem(i_row, 1, QTableWidgetItem("None"))
# SKU, Product Name, Account
self.table.setItem(i_row, 2, QTableWidgetItem(item.get("sku") or ""))
self.table.setItem(i_row, 3, QTableWidgetItem(item.get("product_name") or ""))
self.table.setItem(i_row, 4, QTableWidgetItem(item.get("account_email") or ""))
# Listed At
listed_str = ""
ts = item.get("listed_at")
if ts:
from datetime import datetime
try:
listed_str = datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M")
except Exception:
listed_str = str(ts)
self.table.setItem(i_row, 5, QTableWidgetItem(listed_str))
# Condition, Status
self.table.setItem(i_row, 6, QTableWidgetItem(item.get("condition") or ""))
self.table.setItem(i_row, 7, QTableWidgetItem(item.get("status") or "pending"))
# Actions
btn_menu = QPushButton("Actions")
menu = QMenu()
act_del = QAction("Delete", btn_menu)
act_del.triggered.connect(partial(self.delete_listed, listed_id))
menu.addAction(act_del)
btn_menu.setMenu(menu)
self.table.setCellWidget(i_row, 8, btn_menu)
items_with_index = [(p, i) for i, p in enumerate(page_items)]
if show_progress:
run_with_progress(items_with_index, handler=lambda x: handler(*x), message="Loading listed...", parent=self)
else:
for item in items_with_index:
handler(*item)
# Header sizing
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Fixed)
self.table.setColumnWidth(1, 60)
for idx in range(2, 8):
header.setSectionResizeMode(idx, QHeaderView.Stretch)
header.setSectionResizeMode(8, QHeaderView.Fixed)
self.table.setColumnWidth(8, 100)
# 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)")
# Reset header checkbox
if isinstance(header, CheckBoxHeader):
header.isOn = False
header.viewport().update()
self.update_options_menu()
# --- 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))
menu.addAction(action_reload)
# Filter
action_filter = QAction("Filter", menu)
action_filter.triggered.connect(self.open_filter_dialog)
menu.addAction(action_filter)
# Clear Filter
if self.filters:
action_clear = QAction("Clear Filter", menu)
action_clear.triggered.connect(self.clear_filters)
menu.addAction(action_clear)
# 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())):
action_delete_selected = QAction("Delete Selected", menu)
action_delete_selected.triggered.connect(self.delete_selected)
menu.addAction(action_delete_selected)
self.options_btn.setMenu(menu)
# --- Filter ---
def open_filter_dialog(self):
dialog = ListedFilterDialog(self)
if dialog.exec_():
self.filters = dialog.get_filters()
self.current_page = 0
self.load_data()
def clear_filters(self):
self.filters = {}
self.current_page = 0
self.load_data()
# --- 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()
# --- Select All ---
def select_all_rows(self, checked):
for i in range(self.table.rowCount()):
cb = self.table.cellWidget(i, 0)
if isinstance(cb, QCheckBox):
cb.setChecked(checked)
# --- Delete ---
def delete_selected(self):
ids = [int(cb.property("listed_id")) for i in range(self.table.rowCount())
if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()]
if not ids:
QMessageBox.information(self, "Info", "No listed selected")
return
confirm = QMessageBox.question(
self, "Confirm Delete", f"Delete {len(ids)} selected listed items?",
QMessageBox.Yes | QMessageBox.No
)
if confirm != QMessageBox.Yes:
return
run_with_progress(ids, handler=lambda x: Listed.bulk_delete([x]), message="Deleting listed...", parent=self)
self.current_page = 0
self.load_data()
def delete_listed(self, listed_id):
confirm = QMessageBox.question(
self, "Confirm Delete", f"Delete listed ID {listed_id}?", QMessageBox.Yes | QMessageBox.No
)
if confirm != QMessageBox.Yes:
return
run_with_progress([listed_id], handler=lambda x: Listed.bulk_delete([x]), message="Deleting listed...", parent=self)
self.load_data()

View File

@ -0,0 +1,64 @@
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QComboBox, QPushButton, QMessageBox
from services.core.loading_service import run_with_progress
from database.models.listed import Listed
from database.models.account import Account # import model
class AddListedDialog(QDialog):
def __init__(self, product_ids, parent=None):
super().__init__(parent)
self.setWindowTitle("Add Listed")
self.product_ids = product_ids
self.selected_account = None
layout = QVBoxLayout()
self.combo = QComboBox()
# --- Lấy account trực tiếp ---
accounts = Account.all() # trả về list tuple từ DB
for acc in accounts:
acc_id = acc[0]
acc_email = acc[1]
acc_name = acc[2] if len(acc) > 2 else acc_email
display_text = f"{acc_email} ({acc_name})"
self.combo.addItem(display_text, acc_id)
layout.addWidget(self.combo)
btn_ok = QPushButton("Add Listed")
btn_ok.clicked.connect(self.process_add_listed)
layout.addWidget(btn_ok)
self.setLayout(layout)
def process_add_listed(self):
self.selected_account = self.combo.currentData()
if not self.selected_account:
QMessageBox.warning(self, "Warning", "No account selected")
return
if not self.product_ids:
QMessageBox.warning(self, "Warning", "No products selected")
return
confirm = QMessageBox.question(
self, "Confirm Add Listed",
f"Add {len(self.product_ids)} product(s) to listed under selected account?",
QMessageBox.Yes | QMessageBox.No
)
if confirm != QMessageBox.Yes:
return
def handler(product_id):
try:
Listed.bulk_create([{"product_id": product_id, "account_id": self.selected_account}])
except Exception as e:
print(f"Error adding listed for product {product_id}: {e}")
run_with_progress(
self.product_ids,
handler=handler,
message="Adding listed...",
parent=self
)
QMessageBox.information(self, "Success", f"Added {len(self.product_ids)} product(s) to listed.")
self.accept()

View File

@ -0,0 +1,117 @@
from PyQt5.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QDateEdit, QComboBox,
QDialogButtonBox, QPushButton, QHBoxLayout, QVBoxLayout, QWidget
)
from PyQt5.QtCore import QDate
import json
class FilterDialog(QDialog):
SAMPLE_CATEGORIES = ["Electronics", "Clothing", "Shoes", "Accessories", "Home"]
SAMPLE_CONDITIONS = ["New", "Used", "Refurbished"]
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Filter Products")
self.setMinimumSize(500, 300)
main_layout = QVBoxLayout()
columns_layout = QHBoxLayout()
# --- Column 1 (Basic filters) ---
left_widget = QWidget()
left_form = QFormLayout(left_widget)
self.name_input = QLineEdit()
left_form.addRow("Name:", self.name_input)
self.price_input = QLineEdit()
left_form.addRow("Price:", self.price_input)
self.created_input = QDateEdit()
self.created_input.setCalendarPopup(True)
self.created_input.setDisplayFormat("yyyy-MM-dd")
self.created_input.setDate(QDate.currentDate())
left_form.addRow("Created at:", self.created_input)
self.category_input = QComboBox()
self.category_input.addItem("Any")
self.category_input.addItems(self.SAMPLE_CATEGORIES)
left_form.addRow("Category:", self.category_input)
columns_layout.addWidget(left_widget)
# --- Column 2 (Advanced filters) ---
right_widget = QWidget()
right_form = QFormLayout(right_widget)
self.condition_input = QComboBox()
self.condition_input.addItem("Any")
self.condition_input.addItems(self.SAMPLE_CONDITIONS)
right_form.addRow("Condition:", self.condition_input)
self.brand_input = QLineEdit()
right_form.addRow("Brand:", self.brand_input)
self.tags_input = QLineEdit()
right_form.addRow("Tags (comma separated):", self.tags_input)
self.sku_input = QLineEdit()
right_form.addRow("SKU:", self.sku_input)
self.location_input = QLineEdit()
right_form.addRow("Location:", self.location_input)
columns_layout.addWidget(right_widget)
main_layout.addLayout(columns_layout)
# --- Button Box ---
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 = {}
name = self.name_input.text().strip()
if name:
filters["name"] = name
price_text = self.price_input.text().strip()
if price_text:
try:
filters["price"] = float(price_text)
except ValueError:
pass
if self.created_input.date().isValid():
filters["created_at"] = self.created_input.date().toString("yyyy-MM-dd")
category = self.category_input.currentText()
if category != "Any":
filters["category"] = category
condition = self.condition_input.currentText()
if condition != "Any":
filters["condition"] = condition
brand = self.brand_input.text().strip()
if brand:
filters["brand"] = brand
tags_text = self.tags_input.text().strip()
if tags_text:
filters["tags"] = [t.strip() for t in tags_text.split(",") if t.strip()]
sku = self.sku_input.text().strip()
if sku:
filters["sku"] = sku
location = self.location_input.text().strip()
if location:
filters["location"] = location
return filters

View File

@ -0,0 +1,166 @@
import json
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QComboBox, QTextEdit, QPushButton, QGridLayout,
QFileDialog, QMessageBox
)
from PyQt5.QtCore import Qt
from database.models.product import Product
class ProductForm(QDialog):
SAMPLE_NAMES = [
"Apple iPhone 15", "Samsung Galaxy S23", "Sony Headphones",
"Dell Laptop", "Canon Camera", "Nike Shoes", "Adidas T-Shirt"
]
SAMPLE_CATEGORIES = ["Electronics", "Clothing", "Shoes", "Accessories", "Home"]
SAMPLE_CONDITIONS = ["New", "Used", "Refurbished"]
def __init__(self, parent=None, product=None):
super().__init__(parent)
self.setWindowTitle("Product Form")
self.product = product
self.setMinimumSize(600, 400)
main_layout = QVBoxLayout()
grid = QGridLayout()
# Column 1
grid.addWidget(QLabel("Name"), 0, 0)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText(", ".join(self.SAMPLE_NAMES))
grid.addWidget(self.name_input, 0, 1)
grid.addWidget(QLabel("Price"), 1, 0)
self.price_input = QLineEdit()
grid.addWidget(self.price_input, 1, 1)
grid.addWidget(QLabel("Category"), 2, 0)
self.category_input = QComboBox()
self.category_input.addItem("None")
self.category_input.addItems(self.SAMPLE_CATEGORIES)
grid.addWidget(self.category_input, 2, 1)
grid.addWidget(QLabel("Condition"), 3, 0)
self.condition_input = QComboBox()
self.condition_input.addItem("None")
self.condition_input.addItems(self.SAMPLE_CONDITIONS)
grid.addWidget(self.condition_input, 3, 1)
grid.addWidget(QLabel("Brand"), 4, 0)
self.brand_input = QLineEdit()
grid.addWidget(self.brand_input, 4, 1)
# Column 2
grid.addWidget(QLabel("Description"), 0, 2)
self.description_input = QTextEdit()
self.description_input.setFixedHeight(80)
grid.addWidget(self.description_input, 0, 3)
grid.addWidget(QLabel("Tags (comma separated)"), 1, 2)
self.tags_input = QLineEdit()
grid.addWidget(self.tags_input, 1, 3)
grid.addWidget(QLabel("SKU"), 2, 2)
self.sku_input = QLineEdit()
grid.addWidget(self.sku_input, 2, 3)
grid.addWidget(QLabel("Location"), 3, 2)
self.location_input = QLineEdit()
grid.addWidget(self.location_input, 3, 3)
grid.addWidget(QLabel("Images (comma separated URLs or files)"), 4, 2)
self.images_input = QTextEdit()
self.images_input.setFixedHeight(60)
grid.addWidget(self.images_input, 4, 3)
self.select_file_btn = QPushButton("Select Image File(s)")
self.select_file_btn.clicked.connect(self.select_files)
grid.addWidget(self.select_file_btn, 5, 3)
main_layout.addLayout(grid)
# Buttons
btn_layout = QHBoxLayout()
self.save_btn = QPushButton("Save")
self.save_btn.clicked.connect(self.save)
btn_layout.addWidget(self.save_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self.close)
btn_layout.addWidget(self.cancel_btn)
main_layout.addLayout(btn_layout)
self.setLayout(main_layout)
# Nếu đang edit product
if product:
self.load_product(product)
def load_product(self, product):
self.name_input.setText(product.get("name", ""))
self.price_input.setText(str(product.get("price", 0)))
self.category_input.setCurrentText(product.get("category") or "None")
self.condition_input.setCurrentText(product.get("condition") or "None")
self.brand_input.setText(product.get("brand") or "")
self.description_input.setPlainText(product.get("description") or "")
tags = json.loads(product.get("tags") or "[]")
self.tags_input.setText(",".join(tags))
self.sku_input.setText(product.get("sku") or "")
self.location_input.setText(product.get("location") or "")
images = json.loads(product.get("images") or "[]")
self.images_input.setText(",".join(images))
def select_files(self):
files, _ = QFileDialog.getOpenFileNames(
self, "Select Image Files", "", "Images (*.png *.jpg *.jpeg *.bmp)"
)
if files:
existing = [img.strip() for img in self.images_input.toPlainText().split(",") if img.strip()]
self.images_input.setText(",".join(existing + files))
def save(self):
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Error", "Name cannot be empty")
return
try:
price = float(self.price_input.text().strip())
except ValueError:
QMessageBox.warning(self, "Error", "Price must be a valid number")
return
category = self.category_input.currentText() if self.category_input.currentText() != "None" else None
condition = self.condition_input.currentText() if self.condition_input.currentText() != "None" else None
brand = self.brand_input.text().strip() or None
description = self.description_input.toPlainText().strip() or None
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()] or None
sku = self.sku_input.text().strip() or None
location = self.location_input.text().strip() or None
# Xử lý images: remove dấu ngoặc và quote nếu copy từ JSON
raw_text = self.images_input.toPlainText()
raw_text = raw_text.replace('[', '').replace(']', '').replace('"', '').replace("'", '')
images = [img.strip() for img in raw_text.split(",") if img.strip()]
try:
if self.product and "id" in self.product:
Product.update(
self.product["id"], name, price,
images=images,
url=None, status="draft",
category=category, condition=condition, brand=brand,
description=description, tags=tags, sku=sku, location=location
)
else:
Product.create(
name, price,
images=images,
url=None, status="draft",
category=category, condition=condition, brand=brand,
description=description, tags=tags, sku=sku, location=location
)
self.accept()
except Exception as e:
QMessageBox.warning(self, "Error", str(e))

View File

@ -0,0 +1,416 @@
import json
from functools import partial
from services.core.loading_service import run_with_progress
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QHBoxLayout, QMenu, QAction, QHeaderView,
QLabel, QCheckBox, QSizePolicy, QMessageBox, QStyleOptionButton, QStyle, QStylePainter, QLineEdit
)
from PyQt5.QtCore import Qt, QRect, pyqtSignal
from database.models.product import Product
from database.models.listed import Listed
from services.image_service import ImageService
from gui.tabs.products.forms.product_form import ProductForm
from gui.tabs.products.dialogs.product_filter_dialog import FilterDialog
from gui.tabs.products.dialogs.add_listed_dialog import AddListedDialog
PAGE_SIZE = 10
# --- Header Checkbox ---
class CheckBoxHeader(QHeaderView):
select_all_changed = pyqtSignal(bool)
def __init__(self, orientation, parent=None):
super().__init__(orientation, parent)
self.isOn = False
self.setSectionsClickable(True)
self.sectionPressed.connect(self.handle_section_pressed)
def paintSection(self, painter, rect, logicalIndex):
super().paintSection(painter, rect, logicalIndex)
if logicalIndex == 0:
option = QStyleOptionButton()
size = 20
x = rect.x() + (rect.width() - size) // 2
y = rect.y() + (rect.height() - size) // 2
option.rect = QRect(x, y, size, size)
option.state = QStyle.State_Enabled | (QStyle.State_On if self.isOn else QStyle.State_Off)
painter2 = QStylePainter(self.viewport())
painter2.drawControl(QStyle.CE_CheckBox, option)
def handle_section_pressed(self, logicalIndex):
if logicalIndex == 0:
self.isOn = not self.isOn
self.select_all_changed.emit(self.isOn)
self.viewport().update()
# --- ProductTab ---
class ProductTab(QWidget):
SORTABLE_COLUMNS = {
1: "id",
3: "name",
4: "price",
7: "created_at"
}
def __init__(self):
super().__init__()
self.current_page = 0
self.total_count = 0
self.total_pages = 0
self.filters = {}
self.sort_by = "id"
self.sort_order = "ASC"
layout = QVBoxLayout()
# Top menu
top_layout = QHBoxLayout()
self.add_btn = QPushButton("Add Product")
self.add_btn.clicked.connect(self.add_product)
top_layout.addWidget(self.add_btn)
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
self.table = QTableWidget()
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
header = CheckBoxHeader(Qt.Horizontal, self.table)
self.table.setHorizontalHeader(header)
header.select_all_changed.connect(self.select_all_rows)
header.sectionClicked.connect(self.handle_header_click)
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)
# self.load_data()
# --- Handle Header Click for Sorting ---
def handle_header_click(self, logicalIndex):
if logicalIndex in self.SORTABLE_COLUMNS:
col = self.SORTABLE_COLUMNS[logicalIndex]
if self.sort_by == col:
self.sort_order = "DESC" if self.sort_order == "ASC" else "ASC"
else:
self.sort_by = col
self.sort_order = "ASC"
self.load_data()
# --- Options Menu ---
def update_options_menu(self):
menu = QMenu()
action_reload = QAction("Reload", menu)
action_reload.triggered.connect(lambda: self.load_data(show_progress=True))
menu.addAction(action_reload)
action_filter = QAction("Filter", menu)
action_filter.triggered.connect(self.open_filter_dialog)
menu.addAction(action_filter)
if self.filters:
action_clear = QAction("Clear Filter", menu)
action_clear.triggered.connect(self.clear_filters)
menu.addAction(action_clear)
# --- Thêm Add Listed Selected ---
if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked()
for i in range(self.table.rowCount())):
action_add_listed_selected = QAction("Add Listed Selected", menu)
action_add_listed_selected.triggered.connect(self.add_listed_selected)
menu.addAction(action_add_listed_selected)
if any(isinstance(self.table.cellWidget(i, 0), QCheckBox) and self.table.cellWidget(i, 0).isChecked()
for i in range(self.table.rowCount())):
action_delete_selected = QAction("Delete Selected", menu)
action_delete_selected.triggered.connect(self.delete_selected)
menu.addAction(action_delete_selected)
self.options_btn.setMenu(menu)
# --- Filter ---
def open_filter_dialog(self):
dialog = FilterDialog(self)
if dialog.exec_():
self.filters = dialog.get_filters()
self.current_page = 0
self.load_data()
def clear_filters(self):
self.filters = {}
self.current_page = 0
self.load_data()
# --- Load Data ---
def load_data(self, show_progress=True):
"""
Load products vào table.
- show_progress: nếu True thì hiển thị progress dialog (user trigger), False khi mở app.
"""
self.table.clearContents()
self.table.setRowCount(0)
offset = self.current_page * PAGE_SIZE
# Lấy toàn bộ dữ liệu cần load
page_items, total_count = Product.get_paginated(
offset, PAGE_SIZE, self.filters,
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(9)
columns = ["", "Image", "SKU", "Name", "Price", "Condition", "Brand", "Created At", "Actions"]
self.table.setHorizontalHeaderLabels(columns)
self.table.setRowCount(len(page_items))
def handler(p, i_row):
product_id = p.get("id")
# Checkbox
cb = QCheckBox()
cb.setProperty("product_id", product_id)
self.table.setCellWidget(i_row, 0, cb)
cb.stateChanged.connect(self.update_options_menu)
# Image
images = json.loads(p.get("images") or "[]")
if images:
pixmap = ImageService.get_display_pixmap(images[0], size=60)
if pixmap:
lbl = QLabel()
lbl.setPixmap(pixmap)
lbl.setAlignment(Qt.AlignCenter)
self.table.setCellWidget(i_row, 1, lbl)
else:
self.table.setItem(i_row, 1, QTableWidgetItem("None"))
else:
self.table.setItem(i_row, 1, QTableWidgetItem("None"))
# Các cột text
self.table.setItem(i_row, 2, QTableWidgetItem(p.get("sku") or ""))
self.table.setItem(i_row, 3, QTableWidgetItem(p.get("name") or ""))
self.table.setItem(i_row, 4, QTableWidgetItem(str(p.get("price") or "")))
self.table.setItem(i_row, 5, QTableWidgetItem(p.get("condition") or ""))
self.table.setItem(i_row, 6, QTableWidgetItem(p.get("brand") or ""))
created_str = ""
created_ts = p.get("created_at")
if created_ts:
from datetime import datetime
try:
created_str = datetime.fromtimestamp(int(created_ts)).strftime("%Y-%m-%d %H:%M")
except Exception:
created_str = str(created_ts)
self.table.setItem(i_row, 7, QTableWidgetItem(created_str))
# Actions
btn_menu = QPushButton("Actions")
menu = QMenu()
act_edit = QAction("Edit", btn_menu)
act_edit.triggered.connect(partial(self.edit_product, p))
menu.addAction(act_edit)
act_add_listed = QAction("Add Listed", btn_menu) # <-- thêm action này
act_add_listed.triggered.connect(partial(self.add_listed_row, product_id))
menu.addAction(act_add_listed)
act_del = QAction("Delete", btn_menu)
act_del.triggered.connect(partial(self.delete_product, product_id))
menu.addAction(act_del)
btn_menu.setMenu(menu)
self.table.setCellWidget(i_row, 8, btn_menu)
# --- Hiển thị progress nếu cần ---
items_with_index = [(p, i) for i, p in enumerate(page_items)]
if show_progress:
run_with_progress(
items_with_index,
handler=lambda x: handler(*x),
message="Loading products...",
parent=self
)
else:
for item in items_with_index:
handler(*item)
# Header sizing
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Fixed)
self.table.setColumnWidth(1, 60)
for idx in range(2, 8):
header.setSectionResizeMode(idx, QHeaderView.Stretch)
header.setSectionResizeMode(8, QHeaderView.Fixed)
self.table.setColumnWidth(8, 100)
# 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)")
# Reset header checkbox
if isinstance(header, CheckBoxHeader):
header.isOn = False
header.viewport().update()
self.update_options_menu()
# --- Go to page ---
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
# --- Select All ---
def select_all_rows(self, checked):
for i in range(self.table.rowCount()):
cb = self.table.cellWidget(i, 0)
if isinstance(cb, QCheckBox):
cb.setChecked(checked)
# --- Delete Selected ---
def delete_selected(self):
ids = [
int(cb.property("product_id"))
for i in range(self.table.rowCount())
if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()
]
if not ids:
QMessageBox.information(self, "Info", "No product selected")
return
confirm = QMessageBox.question(
self, "Confirm Delete", f"Delete {len(ids)} selected products?",
QMessageBox.Yes | QMessageBox.No
)
if confirm != QMessageBox.Yes:
return
# --- dùng run_with_progress ---
run_with_progress(
ids,
handler=Product.delete,
message="Deleting products...",
parent=self
)
self.current_page = 0
self.load_data()
# --- Product Actions ---
def add_product(self):
form = ProductForm(self)
if form.exec_():
self.current_page = 0
self.load_data()
def edit_product(self, product):
form = ProductForm(self, product)
if form.exec_():
self.load_data()
def delete_product(self, product_id):
confirm = QMessageBox.question(
self, "Confirm Delete", f"Delete product ID {product_id}?", QMessageBox.Yes | QMessageBox.No
)
if confirm != QMessageBox.Yes:
return
run_with_progress(
[product_id],
handler=Product.delete,
message="Deleting product...",
parent=self
)
self.load_data()
def add_listed_selected(self):
selected_ids = [
int(cb.property("product_id"))
for i in range(self.table.rowCount())
if isinstance(cb := self.table.cellWidget(i, 0), QCheckBox) and cb.isChecked()
]
if not selected_ids:
QMessageBox.information(self, "Info", "No products selected")
return
dialog = AddListedDialog(selected_ids, parent=self)
dialog.exec_()
# --- Clear row checkboxes ---
for i in range(self.table.rowCount()):
cb = self.table.cellWidget(i, 0)
if isinstance(cb, QCheckBox):
cb.setChecked(False)
# --- Reset header checkbox ---
self.reset_header_checkbox()
# --- Update menu ---
self.update_options_menu()
def reset_header_checkbox(self):
header = self.table.horizontalHeader()
if isinstance(header, CheckBoxHeader):
header.isOn = False
header.viewport().update()
# --- Add Listed for single row (row action) ---
def add_listed_row(self, product_id):
if not product_id:
QMessageBox.warning(self, "Warning", "Invalid product")
return
dialog = AddListedDialog([product_id], parent=self)
dialog.exec_()
# --- Pagination ---
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()

View File

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
PyQt5==5.15.11
PyQtWebEngine==5.15.6
opencv-python==4.10.0.84
numpy==1.26.4
SQLAlchemy==2.0.22
requests

21
sample_products.csv Normal file
View File

@ -0,0 +1,21 @@
sku,name,price,condition,brand,images
SKU001,Apple iPhone 13,18000000,New,Apple,https://example.com/iphone13.jpg
SKU002,Samsung Galaxy S21,15000000,Like New,Samsung,https://example.com/galaxys21.jpg
SKU003,Xiaomi Redmi Note 11,6000000,New,Xiaomi,https://example.com/redmi11.jpg
SKU004,Oppo Reno 6,8500000,New,Oppo,https://example.com/reno6.jpg
SKU005,Vivo V21,7000000,Used,Vivo,https://example.com/vivo21.jpg
SKU006,MacBook Air M1,23000000,Like New,Apple,https://example.com/macbookairm1.jpg
SKU007,Asus Zenbook 14,20000000,New,Asus,https://example.com/zenbook14.jpg
SKU008,Dell XPS 13,25000000,New,Dell,https://example.com/xps13.jpg
SKU009,HP Spectre x360,24000000,Like New,HP,https://example.com/spectre.jpg
SKU010,Lenovo ThinkPad X1,22000000,Used,Lenovo,https://example.com/thinkpadx1.jpg
SKU011,AirPods Pro,5500000,New,Apple,https://example.com/airpodspro.jpg
SKU012,Samsung Buds 2,3500000,New,Samsung,https://example.com/buds2.jpg
SKU013,Apple Watch SE,7500000,Like New,Apple,https://example.com/watchse.jpg
SKU014,Gaming Mouse Logitech G502,1800000,New,Logitech,https://example.com/g502.jpg
SKU015,Mechanical Keyboard Keychron K2,2500000,New,Keychron,https://example.com/keychronk2.jpg
SKU016,Sony WH-1000XM4,8500000,New,Sony,https://example.com/xm4.jpg
SKU017,Bose QuietComfort 45,9500000,Like New,Bose,https://example.com/qc45.jpg
SKU018,GoPro Hero 10,12000000,New,GoPro,https://example.com/gopro10.jpg
SKU019,Nikon D5600,15000000,Used,Nikon,https://example.com/d5600.jpg
SKU020,Canon EOS M50,16000000,Like New,Canon,https://example.com/m50.jpg
1 sku name price condition brand images
2 SKU001 Apple iPhone 13 18000000 New Apple https://example.com/iphone13.jpg
3 SKU002 Samsung Galaxy S21 15000000 Like New Samsung https://example.com/galaxys21.jpg
4 SKU003 Xiaomi Redmi Note 11 6000000 New Xiaomi https://example.com/redmi11.jpg
5 SKU004 Oppo Reno 6 8500000 New Oppo https://example.com/reno6.jpg
6 SKU005 Vivo V21 7000000 Used Vivo https://example.com/vivo21.jpg
7 SKU006 MacBook Air M1 23000000 Like New Apple https://example.com/macbookairm1.jpg
8 SKU007 Asus Zenbook 14 20000000 New Asus https://example.com/zenbook14.jpg
9 SKU008 Dell XPS 13 25000000 New Dell https://example.com/xps13.jpg
10 SKU009 HP Spectre x360 24000000 Like New HP https://example.com/spectre.jpg
11 SKU010 Lenovo ThinkPad X1 22000000 Used Lenovo https://example.com/thinkpadx1.jpg
12 SKU011 AirPods Pro 5500000 New Apple https://example.com/airpodspro.jpg
13 SKU012 Samsung Buds 2 3500000 New Samsung https://example.com/buds2.jpg
14 SKU013 Apple Watch SE 7500000 Like New Apple https://example.com/watchse.jpg
15 SKU014 Gaming Mouse Logitech G502 1800000 New Logitech https://example.com/g502.jpg
16 SKU015 Mechanical Keyboard Keychron K2 2500000 New Keychron https://example.com/keychronk2.jpg
17 SKU016 Sony WH-1000XM4 8500000 New Sony https://example.com/xm4.jpg
18 SKU017 Bose QuietComfort 45 9500000 Like New Bose https://example.com/qc45.jpg
19 SKU018 GoPro Hero 10 12000000 New GoPro https://example.com/gopro10.jpg
20 SKU019 Nikon D5600 15000000 Used Nikon https://example.com/d5600.jpg
21 SKU020 Canon EOS M50 16000000 Like New Canon https://example.com/m50.jpg

View File

@ -0,0 +1,39 @@
from PyQt5.QtWidgets import QProgressDialog
from PyQt5.QtCore import Qt, QCoreApplication
def run_with_progress(items, handler, message="Loading...", cancel_text="Cancel", parent=None):
"""
Run the handler for each item in `items` and display a loading progress bar.
- items: iterable (list, tuple, ...)
- handler: function to process each item (may raise errors, won't crash the app)
- message: message displayed on the dialog
- cancel_text: cancel button text
- parent: optional parent QWidget
"""
total = len(items)
if total == 0:
return 0, 0
progress = QProgressDialog(message, cancel_text, 0, total, parent)
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
success_count = 0
fail_count = 0
for i, item in enumerate(items):
if progress.wasCanceled():
break
try:
handler(item)
success_count += 1
except Exception as e:
print(f"Error processing item {i}: {e}")
fail_count += 1
progress.setValue(i + 1)
QCoreApplication.processEvents() # Prevent UI freezing
progress.close()
return success_count, fail_count

41
services/image_service.py Normal file
View File

@ -0,0 +1,41 @@
# services/image_service.py
import os
import requests
from PyQt5.QtGui import QPixmap
from PyQt5.QtCore import Qt
class ImageService:
@staticmethod
def get_display_pixmap(image_path_or_url, size=60):
"""
Load image from local path or URL and return QPixmap scaled.
Returns None if cannot load.
"""
pixmap = QPixmap()
if not image_path_or_url:
return None
# Local file
if os.path.exists(image_path_or_url):
pixmap.load(image_path_or_url)
# URL
elif image_path_or_url.startswith("http"):
try:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
resp = requests.get(image_path_or_url, headers=headers)
if resp.status_code == 200:
pixmap.loadFromData(resp.content)
else:
return None
except Exception as e:
print("Failed to load image from URL:", e)
return None
else:
return None
if not pixmap.isNull():
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return pixmap
return None

View File

View File

0
utils/helpers.py Normal file
View File