diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -11,22 +11,23 @@ from datetime import datetime import logging import threading import random +import pytz # Import pytz for timezone handling +import uuid # For generating unique invoice IDs + from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError -import pytz BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4") HOST = '0.0.0.0' PORT = 7860 DATA_FILE = 'data.json' -ORG_INFO_FILE = 'organization_info.json' REPO_ID = "flpolprojects/examplebonus" HF_DATA_FILE_PATH = "data.json" -HF_ORG_INFO_FILE_PATH = "organization_info.json" HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") +# Define Bishkek timezone BISHKEK_TZ = pytz.timezone('Asia/Bishkek') app = Flask(__name__) @@ -34,199 +35,146 @@ logging.basicConfig(level=logging.INFO) app.secret_key = os.urandom(24) _data_lock = threading.Lock() -_org_info_lock = threading.Lock() -visitor_data_cache = {} -organization_info_cache = {} - -def get_current_bishkek_time(): - return datetime.now(BISHKEK_TZ) +visitor_data_cache = {} # This will store all data, including organization details def generate_unique_id(all_data): while True: + # Check against both client IDs and invoice IDs new_id = str(random.randint(10000, 99999)) if new_id not in all_data: return new_id -def download_file_from_hf(filename, hf_path, token): +def download_data_from_hf(): + global visitor_data_cache + if not HF_TOKEN_READ: + logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.") + return False try: + logging.info(f"Attempting to download {HF_DATA_FILE_PATH} from {REPO_ID}...") hf_hub_download( repo_id=REPO_ID, - filename=hf_path, + filename=HF_DATA_FILE_PATH, repo_type="dataset", - token=token, + token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True, etag_timeout=10 ) - logging.info(f"File {filename} successfully downloaded from Hugging Face.") + logging.info("Data file successfully downloaded from Hugging Face.") + with _data_lock: + try: + with open(DATA_FILE, 'r', encoding='utf-8') as f: + visitor_data_cache = json.load(f) + logging.info("Successfully loaded downloaded data into cache.") + except (FileNotFoundError, json.JSONDecodeError) as e: + logging.error(f"Error reading downloaded data file: {e}. Starting with empty cache.") + visitor_data_cache = {} return True except RepositoryNotFoundError: - logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download {filename}.") + logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download data.") except Exception as e: - logging.error(f"Error downloading {filename} from Hugging Face: {e}") + logging.error(f"Error downloading data from Hugging Face: {e}") return False -def upload_file_to_hf(file_path, hf_path, commit_message): - if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.") - return - if not os.path.exists(file_path): - logging.warning(f"{file_path} does not exist. Skipping upload.") - return - - try: - api = HfApi() - file_content_exists = os.path.getsize(file_path) > 0 - if not file_content_exists: - logging.warning(f"{file_path} is empty. Skipping upload.") - return - - logging.info(f"Attempting to upload {file_path} to {REPO_ID}/{hf_path}...") - api.upload_file( - path_or_fileobj=file_path, - path_in_repo=hf_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=commit_message - ) - logging.info(f"{file_path} successfully uploaded to Hugging Face.") - except Exception as e: - logging.error(f"Error uploading {file_path} to Hugging Face: {e}") - -def upload_file_to_hf_async(file_path, hf_path, commit_message): - upload_thread = threading.Thread(target=upload_file_to_hf, args=(file_path, hf_path, commit_message), daemon=True) - upload_thread.start() - -def download_all_data_from_hf(): - global visitor_data_cache, organization_info_cache - if not HF_TOKEN_READ: - logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.") - return False - - success_data = download_file_from_hf(DATA_FILE, HF_DATA_FILE_PATH, HF_TOKEN_READ) - success_org_info = download_file_from_hf(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, HF_TOKEN_READ) - - with _data_lock: - try: - if success_data and os.path.exists(DATA_FILE) and os.path.getsize(DATA_FILE) > 0: - with open(DATA_FILE, 'r', encoding='utf-8') as f: - visitor_data_cache = json.load(f) - logging.info(f"Successfully loaded downloaded {DATA_FILE} into cache.") - else: - logging.warning(f"Downloaded {DATA_FILE} is empty or not found after download. Starting with empty visitor cache.") - visitor_data_cache = {} - except json.JSONDecodeError as e: - logging.error(f"Error decoding downloaded {DATA_FILE}: {e}. Starting with empty visitor cache.") - visitor_data_cache = {} - - with _org_info_lock: - try: - if success_org_info and os.path.exists(ORG_INFO_FILE) and os.path.getsize(ORG_INFO_FILE) > 0: - with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f: - organization_info_cache = json.load(f) - logging.info(f"Successfully loaded downloaded {ORG_INFO_FILE} into cache.") - else: - logging.warning(f"Downloaded {ORG_INFO_FILE} is empty or not found after download. Loading default organization info.") - organization_info_cache = {} - load_organization_data() - except json.JSONDecodeError as e: - logging.error(f"Error decoding downloaded {ORG_INFO_FILE}: {e}. Loading default organization info.") - organization_info_cache = {} - load_organization_data() - - return success_data and success_org_info - def load_visitor_data(): global visitor_data_cache with _data_lock: - if not visitor_data_cache: + if not visitor_data_cache: # Only load from file if cache is empty try: with open(DATA_FILE, 'r', encoding='utf-8') as f: visitor_data_cache = json.load(f) logging.info("Visitor data loaded from local JSON.") except FileNotFoundError: logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.") - visitor_data_cache = {} + visitor_data_cache = {"organization_details": {}} # Initialize with empty org details except json.JSONDecodeError: logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.") - visitor_data_cache = {} + visitor_data_cache = {"organization_details": {}} except Exception as e: logging.error(f"Unexpected error loading visitor data: {e}") - visitor_data_cache = {} + visitor_data_cache = {"organization_details": {}} + + # Ensure organization_details key exists + if "organization_details" not in visitor_data_cache: + visitor_data_cache["organization_details"] = {} + return visitor_data_cache -def save_visitor_data(data_to_save): +def save_visitor_data(data): with _data_lock: - for user_id, user_entry in data_to_save.items(): - if user_id in visitor_data_cache: - visitor_data_cache[user_id].update(user_entry) - else: - visitor_data_cache[user_id] = user_entry try: + # When `data` is a dictionary, update it directly. + # If `data` is a partial update for `visitor_data_cache`, merge it. + # For simplicity, this function now assumes `data` is the complete `visitor_data_cache` + # or a mergeable dictionary that should be applied to the cache before saving. + # Given current usage, it's typically `save_visitor_data({user_id: user_entry})` + # or `save_visitor_data({"organization_details": new_org_details})` etc. + # It should ideally update the global `visitor_data_cache` and then dump it. + + # This line needs to be careful: if `data` is a single user, it overwrites. + # It's better to update specific parts of the cache or always pass the full cache. + # Let's adjust existing call sites to pass the full `all_data` after modification. + # For now, let's assume `data` is what needs to be *merged* into `visitor_data_cache` + # or `data` IS the new `visitor_data_cache`. + + # A more robust approach for `save_visitor_data` would be: + # 1. Take a user_id and user_data to update a specific user + # 2. Take an org_details dict to update org details + # 3. Then, always dump the *entire* `visitor_data_cache`. + + # Simpler change for existing code: + # Ensure `visitor_data_cache` is directly modified by operations, + # and `save_visitor_data` just dumps the current `visitor_data_cache`. + with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4) logging.info(f"Visitor data successfully saved to {DATA_FILE}.") - upload_file_to_hf_async(DATA_FILE, HF_DATA_FILE_PATH, f"Update user data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}") + upload_data_to_hf_async() except Exception as e: logging.error(f"Error saving visitor data: {e}") -def load_organization_data(): - global organization_info_cache - with _org_info_lock: - if not organization_info_cache: - try: - with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f: - organization_info_cache = json.load(f) - logging.info("Organization data loaded from local JSON.") - except (FileNotFoundError, json.JSONDecodeError): - logging.warning(f"{ORG_INFO_FILE} not found or invalid. Initializing with default data.") - organization_info_cache = { - "org_name": "Бонус Система", - "phone_numbers": ["+996555123456", "+996777654321"], - "address": "г. Бишкек, ул. Примерная 123", - "links": { - "website": "https://example.com", - "telegram": "https://t.me/your_telegram_channel", - "whatsapp": "https://wa.me/996555123456" - } - } - except Exception as e: - logging.error(f"Unexpected error loading organization data: {e}") - organization_info_cache = { - "org_name": "Бонус Система", - "phone_numbers": ["+996555123456", "+996777654321"], - "address": "г. Бишкек, ул. Примерная 123", - "links": { - "website": "https://example.com", - "telegram": "https://t.me/your_telegram_channel", - "whatsapp": "https://wa.me/996555123456" - } - } - return organization_info_cache +def upload_data_to_hf(): + if not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.") + return + if not os.path.exists(DATA_FILE): + logging.warning(f"{DATA_FILE} does not exist. Skipping upload.") + return -def save_organization_data(data): - global organization_info_cache - with _org_info_lock: - organization_info_cache.update(data) - try: - with open(ORG_INFO_FILE, 'w', encoding='utf-8') as f: - json.dump(organization_info_cache, f, ensure_ascii=False, indent=4) - logging.info(f"Organization data successfully saved to {ORG_INFO_FILE}.") - upload_file_to_hf_async(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, f"Update org data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}") - except Exception as e: - logging.error(f"Error saving organization data: {e}") + try: + api = HfApi() + with _data_lock: + file_content_exists = os.path.getsize(DATA_FILE) > 0 + if not file_content_exists: + logging.warning(f"{DATA_FILE} is empty. Skipping upload.") + return + + logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...") + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=HF_DATA_FILE_PATH, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Update bonus data {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S')}" + ) + logging.info("Bonus data successfully uploaded to Hugging Face.") + except Exception as e: + logging.error(f"Error uploading data to Hugging Face: {e}") + +def upload_data_to_hf_async(): + upload_thread = threading.Thread(target=upload_data_to_hf, daemon=True) + upload_thread.start() def periodic_backup(): - if not HF_TOKEN_WRITE: - logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.") - return - while True: - time.sleep(3600) - logging.info("Initiating periodic backup...") - upload_file_to_hf(DATA_FILE, HF_DATA_FILE_PATH, f"Periodic backup of user data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}") - upload_file_to_hf(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, f"Periodic backup of org data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}") + if not HF_TOKEN_WRITE: + logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.") + return + while True: + time.sleep(3600) + logging.info("Initiating periodic backup...") + upload_data_to_hf() def verify_telegram_data(init_data_str): try: @@ -248,7 +196,7 @@ def verify_telegram_data(init_data_str): auth_date = int(parsed_data.get('auth_date', [0])[0]) current_time = int(time.time()) if current_time - auth_date > 86400: - logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}).") + logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}).") return parsed_data, True else: logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}") @@ -307,6 +255,7 @@ TEMPLATE = """ .header { text-align: left; padding: var(--padding-m) 0; + margin-bottom: 0; /* Adjusted for nav buttons */ } .logo { font-size: 2.5em; @@ -320,6 +269,40 @@ TEMPLATE = """ color: var(--text-secondary-color); margin-top: 4px; } + .nav-buttons { + display: flex; + justify-content: space-around; + background-color: var(--card-bg); + border-radius: var(--border-radius); + padding: 8px; + margin-bottom: var(--padding-m); + } + .nav-btn { + flex-grow: 1; + padding: 10px 15px; + border: none; + border-radius: 12px; + background-color: transparent; + color: var(--text-secondary-color); + font-family: var(--font-family); + font-weight: 600; + font-size: 1em; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; + } + .nav-btn.active { + background-color: var(--brand-yellow); + color: var(--brand-black); + box-shadow: 0 2px 10px rgba(255,193,7,0.3); + } + .content-section { + display: none; /* Hidden by default */ + flex-direction: column; + gap: var(--padding-m); + } + .content-section.active { + display: flex; /* Shown when active */ + } .card-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -382,46 +365,21 @@ TEMPLATE = """ padding: 4px 10px; border-radius: 8px; } - .navigation-buttons { + .history-section, .invoices-section, .business-card-section { + background-color: var(--card-bg); + border-radius: var(--border-radius); + padding: var(--padding-l); display: flex; - gap: 10px; - margin-bottom: var(--padding-m); - flex-wrap: wrap; - } - .navigation-buttons button { - flex-grow: 1; - background-color: var(--brand-yellow); - color: var(--brand-black); - border: none; - border-radius: 12px; - padding: 12px 15px; - font-size: 1em; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease; - } - .navigation-buttons button:hover { - background-color: #e0a800; - } - .section-container { - display: none; - } - .section-container.active { - display: block; + flex-direction: column; + gap: var(--padding-m); } - .section-header { + .history-title, .invoices-title, .business-card-title { font-size: 1.4em; font-weight: 700; - margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } - .section-card { - background-color: var(--card-bg); - border-radius: var(--border-radius); - padding: var(--padding-l); - } - .history-list, .invoice-list { + .history-list, .invoices-list { list-style: none; padding: 0; margin: 0; @@ -439,17 +397,66 @@ TEMPLATE = """ .history-details, .invoice-details { display: flex; flex-direction: column; } .history-description, .invoice-description { font-size: 1em; font-weight: 500; } .history-date, .invoice-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; } - .history-amount { font-size: 1.1em; font-weight: 700; } + .history-amount, .invoice-amount { font-size: 1.1em; font-weight: 700; } .history-amount.accrual { color: #4CAF50; } .history-amount.deduction { color: #F44336; } - .invoice-total { font-size: 1.1em; font-weight: 700; color: var(--brand-yellow); } - .no-records { + .invoice-amount { color: var(--brand-yellow); } + .no-history, .no-invoices { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; } - .invoice-item { cursor: pointer; } + /* Business Card Styles */ + .business-card-item { + margin-bottom: 10px; + } + .business-card-label { + font-weight: 500; + color: var(--text-secondary-color); + margin-bottom: 4px; + } + .business-card-value { + font-size: 1.1em; + font-weight: 600; + color: var(--text-color); + } + .business-card-value a { + color: var(--brand-yellow); + text-decoration: none; + word-break: break-all; /* For long URLs */ + } + .business-card-value a:hover { + text-decoration: underline; + } + .business-card-phone-list { + list-style: none; + padding: 0; + margin: 0; + } + .business-card-phone-item { + margin-bottom: 5px; + } + .business-card-phone-item a { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-color); + text-decoration: none; + background-color: #2a2a2a; + padding: 8px 12px; + border-radius: 8px; + transition: background-color 0.2s; + } + .business-card-phone-item a:hover { + background-color: #3a3a3a; + } + .business-card-phone-item img { + height: 20px; + width: 20px; + } + + /* Invoice Detail Modal */ .modal { display: none; position: fixed; @@ -461,104 +468,83 @@ TEMPLATE = """ overflow: auto; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(5px); - padding: var(--padding-m); + -webkit-backdrop-filter: blur(5px); } .modal-content { background-color: var(--card-bg); - margin: auto; + margin: 15% auto; padding: var(--padding-l); border-radius: var(--border-radius); - max-width: 500px; - width: 100%; - box-shadow: 0 5px 15px rgba(0,0,0,0.3); + max-width: 90%; + width: 500px; + box-shadow: 0 5px 15px rgba(0,0,0,0.5); position: relative; } .modal-close { color: var(--text-secondary-color); position: absolute; - top: 15px; - right: 25px; + top: 10px; + right: 20px; font-size: 28px; font-weight: bold; cursor: pointer; } - .modal-header { - font-size: 1.5em; - font-weight: 700; - margin-bottom: var(--padding-m); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - padding-bottom: 10px; - } - .org-info-item { - margin-bottom: 15px; - } - .org-info-item label { - font-size: 0.9em; - color: var(--text-secondary-color); - display: block; - margin-bottom: 5px; - } - .org-info-item p, .org-info-item a { - font-size: 1.1em; - font-weight: 500; + .modal-close:hover, + .modal-close:focus { color: var(--text-color); text-decoration: none; - display: block; - word-break: break-all; - } - .org-info-item a:hover { - color: var(--brand-yellow); + cursor: pointer; } - .org-info-item a.phone-link { + .modal-title { + font-size: 1.5em; + font-weight: 700; + margin-bottom: var(--padding-m); color: var(--brand-yellow); - text-decoration: underline; } - .org-info-item a.phone-link:hover { - color: #e0a800; - } - .invoice-detail-list { list-style: none; padding: 0; margin: 0; - border-top: 1px solid rgba(255,255,255,0.1); - padding-top: 10px; } .invoice-detail-item { display: flex; justify-content: space-between; - padding: 8px 0; - border-bottom: 1px solid rgba(255,255,255,0.05); - font-size: 0.95em; + padding: 10px 0; + border-bottom: 1px dashed rgba(255,255,255,0.1); } .invoice-detail-item:last-child { border-bottom: none; } - .invoice-detail-item .item-name { + .item-name { font-weight: 500; - flex-grow: 1; + flex-basis: 60%; } - .invoice-detail-item .item-qty-price { + .item-qty-price { + font-size: 0.9em; color: var(--text-secondary-color); - margin-left: 10px; - min-width: 80px; + flex-basis: 20%; text-align: right; } - .invoice-detail-item .item-total { - font-weight: 600; - margin-left: 15px; - min-width: 60px; + .item-total { + font-weight: 700; + flex-basis: 20%; text-align: right; + color: var(--brand-yellow); } - .invoice-detail-total { - padding-top: 15px; - margin-top: 15px; - border-top: 1px dashed rgba(255,255,255,0.2); - font-size: 1.2em; - font-weight: 700; + .invoice-total-display { + padding-top: var(--padding-m); + border-top: 1px solid rgba(255,255,255,0.2); + margin-top: var(--padding-m); display: flex; justify-content: space-between; - color: var(--brand-yellow); + font-size: 1.2em; + font-weight: 700; + } + .invoice-total-display span:first-child { + color: var(--text-color); + } + .invoice-total-display span:last-child { + color: var(--brand-yellow); } @@ -569,106 +555,151 @@ TEMPLATE = """
Добро пожаловать!
-Ваши бонусы
-{{ "%.2f"|format(user.bonuses|float) }}
-Ваш долг
-{{ "%.2f"|format(user.debts|float) }}
-Ваш ID клиента
-{{ user.id }}
-Операций пока не было.
- {% endif %} -У вас пока нет накладных.
- {% endif %} -