| """
|
| In-Memory Cards Database v2.0
|
| COMPLETE REWRITE to fix the disappearing cards bug.
|
|
|
| Root cause of original bug:
|
| - gevent greenlets switching during dict operations caused partial reads
|
| - Multiple code paths mutated dict values in-place causing corruption
|
| - Shallow copies let external code mutate internal state
|
|
|
| Fix approach:
|
| - ALL reads go through a single locked method that returns deep copies
|
| - ALL writes go through a single locked method
|
| - Internal dict is NEVER exposed directly
|
| - Every operation is fully atomic under one lock
|
| """
|
|
|
| import copy
|
| import json
|
| import threading
|
| from datetime import datetime
|
|
|
|
|
| class CardsMemoryDB:
|
| """
|
| Thread-safe and greenlet-safe in-memory cards database.
|
|
|
| EVERY read and write goes through self._lock.
|
| This is slightly slower but 100% correct.
|
| For our scale (< 10K cards, < 1000 req/s) this is more than fast enough.
|
| """
|
|
|
| def __init__(self):
|
|
|
| self._data = {}
|
|
|
|
|
| self._lock = threading.RLock()
|
|
|
|
|
| self._counters = {
|
| 'reads': 0,
|
| 'writes': 0,
|
| 'redeems': 0,
|
| 'redeems_failed': 0,
|
| 'generates': 0,
|
| 'deletes': 0,
|
| 'validates': 0,
|
| 'started_at': datetime.now().isoformat(),
|
| 'last_backup': None,
|
| 'last_restore': None,
|
| 'last_backup_error': None,
|
| }
|
|
|
|
|
| self._github = None
|
| self._init_github()
|
|
|
|
|
| self._load_from_github()
|
|
|
| count = len(self._data)
|
| print(f" [DB] Initialized with {count} cards")
|
|
|
| def _init_github(self):
|
| """Initialize GitHub storage."""
|
| try:
|
| from github_storage import GitHubCardsStorage
|
| self._github = GitHubCardsStorage()
|
| except Exception as e:
|
| print(f" [DB] GitHub init error: {e}")
|
| self._github = None
|
|
|
| def _load_from_github(self):
|
| """Load cards from GitHub on startup."""
|
| if not self._github or not self._github.is_configured():
|
| print(" [DB] GitHub not configured - starting empty")
|
| return
|
|
|
| try:
|
| print(" [DB] Loading from GitHub...")
|
| data = self._github.pull_cards()
|
|
|
| if data and isinstance(data, dict):
|
| with self._lock:
|
| self._data = {}
|
| for code, card in data.items():
|
|
|
| self._data[str(code)] = {
|
| "code": str(card.get("code", code)),
|
| "serial": str(card.get("serial", "")),
|
| "class": str(card.get("class", "0")),
|
| "used": bool(card.get("used", False)),
|
| "used_by": card.get("used_by", None),
|
| "used_at": card.get("used_at", None),
|
| "created_at": str(card.get("created_at", "")),
|
| }
|
| print(f" [DB] Loaded {len(self._data)} cards from GitHub")
|
| else:
|
| print(" [DB] No cards on GitHub (first run)")
|
|
|
| except Exception as e:
|
| print(f" [DB] GitHub load error: {e}")
|
|
|
|
|
|
|
|
|
|
|
| def get_card(self, code):
|
| """
|
| Get a card by code. Returns a COPY or None.
|
| 100% safe - external code cannot corrupt internal state.
|
| """
|
| code = str(code).strip()
|
| with self._lock:
|
| card = self._data.get(code)
|
| if card is None:
|
| return None
|
| self._counters['reads'] += 1
|
|
|
| return {
|
| "code": card["code"],
|
| "serial": card["serial"],
|
| "class": card["class"],
|
| "used": card["used"],
|
| "used_by": card["used_by"],
|
| "used_at": card["used_at"],
|
| "created_at": card["created_at"],
|
| }
|
|
|
| def card_exists(self, code):
|
| """Check if card exists."""
|
| code = str(code).strip()
|
| with self._lock:
|
| return code in self._data
|
|
|
| def get_card_count(self):
|
| """Get total card count."""
|
| with self._lock:
|
| return len(self._data)
|
|
|
| def get_all_cards_copy(self):
|
| """
|
| Get a complete deep copy of all cards.
|
| Used by admin list and backup.
|
| """
|
| with self._lock:
|
| self._counters['reads'] += 1
|
| result = {}
|
| for code, card in self._data.items():
|
| result[code] = {
|
| "code": card["code"],
|
| "serial": card["serial"],
|
| "class": card["class"],
|
| "used": card["used"],
|
| "used_by": card["used_by"],
|
| "used_at": card["used_at"],
|
| "created_at": card["created_at"],
|
| }
|
| return result
|
|
|
|
|
|
|
|
|
|
|
| def redeem_card(self, code, username):
|
| """
|
| Atomically redeem a card.
|
| Returns (success, card_copy_or_error_string).
|
| """
|
| code = str(code).strip()
|
| username = str(username).strip()
|
|
|
| with self._lock:
|
| card = self._data.get(code)
|
|
|
| if card is None:
|
| self._counters['redeems_failed'] += 1
|
| return False, "كود الكرت غير صحيح"
|
|
|
| if card["used"]:
|
| self._counters['redeems_failed'] += 1
|
| return False, "هذا الكرت مستخدم مسبقاً"
|
|
|
|
|
| now_str = datetime.now().isoformat()
|
| card["used"] = True
|
| card["used_by"] = username
|
| card["used_at"] = now_str
|
|
|
| self._counters['redeems'] += 1
|
| self._counters['writes'] += 1
|
|
|
|
|
| return True, {
|
| "code": card["code"],
|
| "serial": card["serial"],
|
| "class": card["class"],
|
| "used": card["used"],
|
| "used_by": card["used_by"],
|
| "used_at": card["used_at"],
|
| "created_at": card["created_at"],
|
| }
|
|
|
| def add_cards_bulk(self, cards_dict):
|
| """
|
| Add multiple cards at once.
|
| cards_dict = {code: card_data, ...}
|
| """
|
| with self._lock:
|
| for code, card in cards_dict.items():
|
| code = str(code).strip()
|
| self._data[code] = {
|
| "code": str(card.get("code", code)),
|
| "serial": str(card.get("serial", "")),
|
| "class": str(card.get("class", "0")),
|
| "used": bool(card.get("used", False)),
|
| "used_by": card.get("used_by", None),
|
| "used_at": card.get("used_at", None),
|
| "created_at": str(card.get("created_at", "")),
|
| }
|
|
|
| added = len(cards_dict)
|
| self._counters['generates'] += added
|
| self._counters['writes'] += added
|
|
|
| total = len(self._data)
|
| print(f" [DB] Added {added} cards. Total now: {total}")
|
| return total
|
|
|
| def add_card(self, code, card_data):
|
| """Add a single card."""
|
| return self.add_cards_bulk({code: card_data})
|
|
|
| def delete_card(self, code):
|
| """Delete a card by code."""
|
| code = str(code).strip()
|
| with self._lock:
|
| if code in self._data:
|
| del self._data[code]
|
| self._counters['deletes'] += 1
|
| self._counters['writes'] += 1
|
| print(f" [DB] Deleted card {code}. Total now: {len(self._data)}")
|
| return True
|
| return False
|
|
|
| def replace_all(self, new_data):
|
| """Replace entire database (for restore)."""
|
| with self._lock:
|
| self._data = {}
|
| if isinstance(new_data, dict):
|
| for code, card in new_data.items():
|
| code = str(code).strip()
|
| self._data[code] = {
|
| "code": str(card.get("code", code)),
|
| "serial": str(card.get("serial", "")),
|
| "class": str(card.get("class", "0")),
|
| "used": bool(card.get("used", False)),
|
| "used_by": card.get("used_by", None),
|
| "used_at": card.get("used_at", None),
|
| "created_at": str(card.get("created_at", "")),
|
| }
|
|
|
| self._counters['last_restore'] = datetime.now().isoformat()
|
| total = len(self._data)
|
| print(f" [DB] Replaced all data. Total now: {total}")
|
| return total
|
|
|
|
|
|
|
|
|
|
|
| def push_to_github(self):
|
| """Push current data to GitHub."""
|
| if not self._github or not self._github.is_configured():
|
| return False, "GitHub not configured"
|
|
|
|
|
| with self._lock:
|
| snapshot = {}
|
| for code, card in self._data.items():
|
| snapshot[code] = {
|
| "code": card["code"],
|
| "serial": card["serial"],
|
| "class": card["class"],
|
| "used": card["used"],
|
| "used_by": card["used_by"],
|
| "used_at": card["used_at"],
|
| "created_at": card["created_at"],
|
| }
|
| snapshot_count = len(snapshot)
|
|
|
| print(f" [DB] Pushing {snapshot_count} cards to GitHub...")
|
|
|
|
|
| success, error = self._github.push_cards(snapshot)
|
|
|
| with self._lock:
|
| if success:
|
| self._counters['last_backup'] = datetime.now().isoformat()
|
| self._counters['last_backup_error'] = None
|
| else:
|
| self._counters['last_backup_error'] = error
|
|
|
| return success, error
|
|
|
| def pull_from_github(self):
|
| """Pull from GitHub and replace all data."""
|
| if not self._github or not self._github.is_configured():
|
| return False, "GitHub not configured"
|
|
|
| print(" [DB] Pulling from GitHub...")
|
|
|
| try:
|
| data = self._github.pull_cards()
|
|
|
| if isinstance(data, dict):
|
| total = self.replace_all(data)
|
| print(f" [DB] Restored {total} cards from GitHub")
|
| return True, None
|
| else:
|
| return False, "Invalid data from GitHub"
|
|
|
| except Exception as e:
|
| err = f"Pull error: {e}"
|
| print(f" [DB] {err}")
|
| return False, err
|
|
|
|
|
|
|
|
|
|
|
| def get_stats(self):
|
| """Get complete database statistics. All under lock for consistency."""
|
| with self._lock:
|
| total = len(self._data)
|
| used = 0
|
| unused = 0
|
| by_class = {}
|
|
|
| for card in self._data.values():
|
| is_used = card["used"]
|
| card_class = card["class"]
|
|
|
| if is_used:
|
| used += 1
|
| else:
|
| unused += 1
|
|
|
| if card_class not in by_class:
|
| by_class[card_class] = {'total': 0, 'used': 0, 'unused': 0}
|
| by_class[card_class]['total'] += 1
|
| if is_used:
|
| by_class[card_class]['used'] += 1
|
| else:
|
| by_class[card_class]['unused'] += 1
|
|
|
| counters_copy = dict(self._counters)
|
|
|
| return {
|
| 'cards': {
|
| 'total': total,
|
| 'used': used,
|
| 'unused': unused,
|
| 'used_percentage': round((used / total * 100), 1) if total > 0 else 0,
|
| },
|
| 'by_class': by_class,
|
| 'operations': counters_copy,
|
| }
|
|
|
| def increment_stat(self, name, count=1):
|
| """Increment a stat counter."""
|
| with self._lock:
|
| if name in self._counters and isinstance(self._counters[name], int):
|
| self._counters[name] += count
|
|
|
| def debug_dump(self):
|
| """
|
| Debug: dump all card codes and classes.
|
| Use this to verify data integrity.
|
| """
|
| with self._lock:
|
| total = len(self._data)
|
| classes = {}
|
| for code, card in self._data.items():
|
| c = card["class"]
|
| if c not in classes:
|
| classes[c] = 0
|
| classes[c] += 1
|
|
|
| print(f" [DB DEBUG] Total: {total}, By class: {classes}")
|
| return {
|
| "total": total,
|
| "by_class": classes,
|
| "codes": list(self._data.keys()),
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| def get_db():
|
| """
|
| Get DB instance from Flask app config.
|
| This ensures ALL code uses the SAME dict instance.
|
| """
|
| from flask import current_app
|
| return current_app.config['DB'] |