346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""
|
|
Firebase → Django mahsulotlarni sinxronlashtirish skripti.
|
|
|
|
Ishlatish:
|
|
python sync_firebase_products.py
|
|
python sync_firebase_products.py --dry-run
|
|
python sync_firebase_products.py --update
|
|
python sync_firebase_products.py --after "2024-01-15"
|
|
python sync_firebase_products.py --after "2024-01-15 10:30:00"
|
|
|
|
Logika:
|
|
- firestore_id bo'yicha tekshiradi
|
|
- Mavjud bo'lsa — o'tkazib ketadi (--update bilan yangilaydi)
|
|
- Yangi bo'lsa — create qiladi
|
|
- --after bilan faqat o'sha vaqtdan keyin qo'shilganlarni oladi
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import django
|
|
import firebase_admin
|
|
from firebase_admin import credentials, firestore
|
|
from decimal import Decimal, InvalidOperation
|
|
from datetime import datetime, timezone
|
|
import requests
|
|
from django.core.files.base import ContentFile
|
|
|
|
# ── Django sozlash ───────────────────────────────────────────────────────────
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, BASE_DIR)
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
|
|
|
try:
|
|
django.setup()
|
|
except Exception as e:
|
|
print(f"[XATO] Django setup: {e}")
|
|
sys.exit(1)
|
|
|
|
from core.apps.vendors.models import (
|
|
VendorproductModel,
|
|
CategoryModel,
|
|
SectionModel,
|
|
ProductAttributeModel,
|
|
ProductVariantModel,
|
|
)
|
|
|
|
# ── Firebase ulanish ─────────────────────────────────────────────────────────
|
|
CERT_PATH = os.path.join(BASE_DIR, "fondexuzb-firebase-adminsdk-fbsvc-7b0e2d6200.json")
|
|
|
|
if not os.path.exists(CERT_PATH):
|
|
print(f"[XATO] Firebase credentials topilmadi: {CERT_PATH}")
|
|
sys.exit(1)
|
|
|
|
if not firebase_admin._apps:
|
|
cred = credentials.Certificate(CERT_PATH)
|
|
firebase_admin.initialize_app(cred)
|
|
|
|
db = firestore.client()
|
|
|
|
|
|
# ── Yordamchi funksiyalar ────────────────────────────────────────────────────
|
|
|
|
def to_decimal(value, default=Decimal("0")):
|
|
try:
|
|
return Decimal(str(value)).quantize(Decimal("0.01"))
|
|
except (InvalidOperation, TypeError):
|
|
return default
|
|
|
|
|
|
def to_int(value, default=-1):
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def parse_after_date(date_str: str) -> datetime:
|
|
"""'2024-01-15' yoki '2024-01-15 10:30:00' ni timezone-aware datetime'ga o'tkazadi."""
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
try:
|
|
dt = datetime.strptime(date_str, fmt)
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(
|
|
f"Noto'g'ri sana formati: '{date_str}'\n"
|
|
"To'g'ri formatlar: '2024-01-15' yoki '2024-01-15 10:30:00'"
|
|
)
|
|
|
|
|
|
def get_category(firestore_id: str):
|
|
if not firestore_id:
|
|
return None
|
|
return CategoryModel.objects.filter(firestore_id=firestore_id).first()
|
|
|
|
|
|
def get_section(firestore_id: str):
|
|
if not firestore_id:
|
|
return None
|
|
return SectionModel.objects.filter(firestore_id=firestore_id).first()
|
|
|
|
|
|
def parse_spec(spec):
|
|
"""product_specification ni dict'ga o'tkazadi."""
|
|
if isinstance(spec, dict) and spec:
|
|
return spec
|
|
if isinstance(spec, str):
|
|
try:
|
|
parsed = json.loads(spec)
|
|
if isinstance(parsed, dict) and parsed:
|
|
return parsed
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def build_product_data(doc_id: str, data: dict) -> dict:
|
|
return dict(
|
|
firestore_id=doc_id,
|
|
vendor=data.get("vendorID") or data.get("vendor") or "",
|
|
name=(data.get("name") or "").strip() or "—",
|
|
description=data.get("description") or "",
|
|
price=to_decimal(data.get("price", 0)),
|
|
discount_price=to_decimal(data.get("disPrice", 0)),
|
|
quantity=to_int(data.get("quantity", -1)),
|
|
is_publish=bool(data.get("publish", True)),
|
|
photos_json=data.get("photos") or None,
|
|
product_specification=parse_spec(data.get("product_specification")),
|
|
_category_id=data.get("categoryID") or "",
|
|
_section_id=data.get("section_id") or "",
|
|
)
|
|
|
|
def print_progress(current, total, created, updated, skipped, errors):
|
|
percent = int(current / total * 100) if total else 0
|
|
bar_filled = percent // 5
|
|
bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
|
line = (
|
|
f"\r [{bar}] {percent:3d}% "
|
|
f"{current}/{total} | "
|
|
f"✓ {created} ↑ {updated} — {skipped} ✗ {errors}"
|
|
)
|
|
print(line, end="", flush=True)
|
|
|
|
|
|
def download_and_save_image(url: str, model_instance, field_name: str, filename: str):
|
|
"""Rasmni yuklab oladi va modelning ko'rsatilgan maydoniga saqlaydi."""
|
|
if not url or not url.startswith("http"):
|
|
return False
|
|
try:
|
|
response = requests.get(url, timeout=10)
|
|
if response.status_code == 200:
|
|
content = ContentFile(response.content)
|
|
field = getattr(model_instance, field_name)
|
|
# S3'da duplicatelarning oldini olish uchun save(name, content, save=True)
|
|
field.save(filename, content, save=True)
|
|
return True
|
|
except Exception as e:
|
|
print(f"\n [IMG XATO] {url}: {e}")
|
|
return False
|
|
|
|
|
|
def sync_attributes():
|
|
print("Firebase vendor_attributes kolleksiyasi o'qilmoqda…")
|
|
docs = db.collection("vendor_attributes").stream()
|
|
count = 0
|
|
for doc in docs:
|
|
data = doc.to_dict()
|
|
attr_id = data.get("id") or doc.id
|
|
name = data.get("title") or "Unnamed"
|
|
|
|
attr, created = ProductAttributeModel.objects.update_or_create(
|
|
firestore_id=attr_id,
|
|
defaults={"name": name}
|
|
)
|
|
count += 1
|
|
print(f"Jami {count} ta atribut sinxronlandi.\n")
|
|
|
|
|
|
# ── Asosiy funksiya ───────────────────────────────────────────────────────────
|
|
|
|
def sync_products(dry_run: bool = False, update: bool = False, after: datetime | None = None):
|
|
print("Firebase vendor_products kolleksiyasi o'qilmoqda…")
|
|
|
|
query = db.collection("vendor_products")
|
|
if after:
|
|
# Firestore'da createdAt field bo'yicha server tomonida filter
|
|
query = query.where("createdAt", ">=", after)
|
|
print(f"Filter: createdAt >= {after.strftime('%Y-%m-%d %H:%M:%S')} (UTC)")
|
|
|
|
docs = list(query.stream())
|
|
total = len(docs)
|
|
print(f"Jami Firebase'da: {total} ta mahsulot\n")
|
|
|
|
if total == 0:
|
|
print("Sinxronlash uchun mahsulot topilmadi.")
|
|
return
|
|
|
|
created = updated = skipped = errors = 0
|
|
|
|
for i, doc in enumerate(docs, start=1):
|
|
doc_id = doc.id
|
|
data = doc.to_dict() or {}
|
|
|
|
if not data.get("name"):
|
|
skipped += 1
|
|
print_progress(i, total, created, updated, skipped, errors)
|
|
continue
|
|
|
|
try:
|
|
product_data = build_product_data(doc_id, data)
|
|
category_fid = product_data.pop("_category_id")
|
|
section_fid = product_data.pop("_section_id")
|
|
|
|
exists = VendorproductModel.objects.filter(firestore_id=doc_id).first()
|
|
|
|
if exists and not update:
|
|
skipped += 1
|
|
print_progress(i, total, created, updated, skipped, errors)
|
|
continue
|
|
|
|
category = get_category(category_fid)
|
|
section = get_section(section_fid)
|
|
|
|
if exists and update:
|
|
for field, val in product_data.items():
|
|
setattr(exists, field, val)
|
|
exists.category = category
|
|
exists.section = section
|
|
if not dry_run:
|
|
exists.save()
|
|
product_obj = exists
|
|
updated += 1
|
|
else:
|
|
product_obj = None
|
|
if not dry_run:
|
|
product_obj = VendorproductModel.objects.create(
|
|
**product_data,
|
|
category=category,
|
|
section=section,
|
|
)
|
|
created += 1
|
|
|
|
# ── Rasmni yuklab olish (Asosiy mahsulot uchun) ────────────────────
|
|
|
|
if not dry_run and product_obj:
|
|
# Agar asosiy rasm bo'lmasa, uni yuklab olamiz
|
|
if not product_obj.image and product_data.get("photos_json"):
|
|
first_photo = product_data["photos_json"][0]
|
|
if isinstance(first_photo, str):
|
|
ext = first_photo.split(".")[-1].split("?")[0] or "jpg"
|
|
download_and_save_image(
|
|
first_photo,
|
|
product_obj,
|
|
"image",
|
|
f"product_{doc_id}.{ext}"
|
|
)
|
|
|
|
# ── Variantlarni qayta ishlash ────────────────────────────────────
|
|
|
|
item_attr = data.get("item_attribute")
|
|
if item_attr and isinstance(item_attr, dict) and not dry_run:
|
|
# Agar product_obj yaratilgan bo'lsa (yoki mavjud bo'lsa)
|
|
target_product = product_obj or exists
|
|
if target_product:
|
|
variants_data = item_attr.get("variants") or []
|
|
for v_data in variants_data:
|
|
v_id = v_data.get("variant_id")
|
|
if not v_id:
|
|
continue
|
|
|
|
ProductVariantModel.objects.update_or_create(
|
|
firestore_id=v_id,
|
|
defaults={
|
|
"product": target_product,
|
|
"price": to_decimal(v_data.get("variant_price", 0)),
|
|
"sku": v_data.get("variant_sku"),
|
|
"quantity": to_int(v_data.get("variant_quantity", -1)),
|
|
"attribute_data": item_attr.get("attributes")
|
|
}
|
|
)
|
|
|
|
# Variant rasm yuklash
|
|
v_obj = ProductVariantModel.objects.filter(firestore_id=v_id).first()
|
|
if v_obj and not v_obj.image and v_data.get("variant_image"):
|
|
v_img_url = v_data["variant_image"]
|
|
ext = v_img_url.split(".")[-1].split("?")[0] or "jpg"
|
|
download_and_save_image(
|
|
v_img_url,
|
|
v_obj,
|
|
"image",
|
|
f"variant_{v_id}.{ext}"
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"\n [XATO] {doc_id}: {e}")
|
|
errors += 1
|
|
|
|
print_progress(i, total, created, updated, skipped, errors)
|
|
|
|
print("\n\n" + "=" * 50)
|
|
print(f"Yaratildi : {created}")
|
|
if update:
|
|
print(f"Yangilandi: {updated}")
|
|
print(f"O'tkazildi: {skipped}")
|
|
if errors:
|
|
print(f"Xatolar : {errors}")
|
|
if dry_run:
|
|
print("\n[DRY-RUN] Hech narsa saqlanmadi.")
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Firebase → Django mahsulot sync")
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Haqiqatda DB'ga yozmaydi, faqat natijalari ko'rsatadi",
|
|
)
|
|
parser.add_argument(
|
|
"--update",
|
|
action="store_true",
|
|
help="Mavjud mahsulotlarni ham yangilaydi (default: o'tkazib ketadi)",
|
|
)
|
|
parser.add_argument(
|
|
"--after",
|
|
type=str,
|
|
default=None,
|
|
metavar="SANA",
|
|
help="Faqat shu vaqtdan keyin qo'shilgan mahsulotlar. Misol: '2024-01-15' yoki '2024-01-15 10:30:00'",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
after_dt: datetime | None = None
|
|
if args.after:
|
|
try:
|
|
after_dt = parse_after_date(args.after)
|
|
except ValueError as e:
|
|
print(f"[XATO] {e}")
|
|
sys.exit(1)
|
|
|
|
sync_attributes()
|
|
sync_products(dry_run=args.dry_run, update=args.update, after=after_dt)
|