""" 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): # The ONLY data store - never expose this directly self._data = {} # Single lock for ALL operations - simple and correct self._lock = threading.RLock() # Operation counters 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, } # GitHub storage instance self._github = None self._init_github() # Load initial data 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(): # Deep copy each card to ensure isolation 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}") # ═══════════════════════════════════════════════════════════ # CORE READ OPERATIONS # ═══════════════════════════════════════════════════════════ 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 a fresh dict copy 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 # ═══════════════════════════════════════════════════════════ # CORE WRITE OPERATIONS # ═══════════════════════════════════════════════════════════ 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, "هذا الكرت مستخدم مسبقاً" # Update in place - we're under lock so this is safe 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 a copy 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 # ═══════════════════════════════════════════════════════════ # GITHUB OPERATIONS # ═══════════════════════════════════════════════════════════ 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" # Take snapshot under lock 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...") # Push WITHOUT holding lock (network call) 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 # ═══════════════════════════════════════════════════════════ # STATISTICS # ═══════════════════════════════════════════════════════════ 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()), } # ═══════════════════════════════════════════════════════════ # Helper function for blueprints to get DB from app context # ═══════════════════════════════════════════════════════════ 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']