diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -7,12 +7,13 @@ import hashlib import json from urllib.parse import unquote, parse_qs, quote import time -from datetime import datetime, timezone, timedelta +from datetime import datetime import logging import threading import random from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError +import pytz # Added for timezone support BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4") HOST = '0.0.0.0' @@ -29,18 +30,25 @@ logging.basicConfig(level=logging.INFO) app.secret_key = os.urandom(24) _data_lock = threading.Lock() -visitor_data_cache = {} +visitor_data_cache = {} # Will hold {"users": {}, "organization_info": {}} -BISHKEK_TZ = timezone(timedelta(hours=6)) +BISHKEK_TZ = pytz.timezone('Asia/Bishkek') -def generate_unique_id(all_data): +DEFAULT_ORGANIZATION_INFO = { + "name": "Название вашей организации", + "phone_numbers": ["+996 (XXX) XX-XX-XX"], + "address": "г. Бишкек, ул. Примерная, д. 1", + "links": [{"label": "Веб-сайт", "url": "https://example.com"}] +} + +def generate_unique_id(users_data): while True: new_id = str(random.randint(10000, 99999)) - if new_id not in all_data: + if new_id not in users_data: return new_id def generate_invoice_id(): - return f"inv_{int(time.time() * 1000)}_{random.randint(100,999)}" + return f"INV-{int(time.time() * 1000)}-{random.randint(100,999)}" def download_data_from_hf(): global visitor_data_cache @@ -63,11 +71,19 @@ def download_data_from_hf(): with _data_lock: try: with open(DATA_FILE, 'r', encoding='utf-8') as f: - visitor_data_cache = json.load(f) + loaded_data = json.load(f) + if not isinstance(loaded_data, dict): # Basic check for structure + raise json.JSONDecodeError("Root is not a dictionary", "", 0) + visitor_data_cache = loaded_data 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 = {} + logging.error(f"Error reading downloaded data file: {e}. Initializing with default structure.") + visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} + # Ensure essential keys exist + if 'users' not in visitor_data_cache: + visitor_data_cache['users'] = {} + if 'organization_info' not in visitor_data_cache: + visitor_data_cache['organization_info'] = DEFAULT_ORGANIZATION_INFO.copy() return True except RepositoryNotFoundError: logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download data.") @@ -78,32 +94,41 @@ def download_data_from_hf(): def load_visitor_data(): global visitor_data_cache with _data_lock: - if not visitor_data_cache: + if not visitor_data_cache or 'users' not in visitor_data_cache: # Ensure it's loaded and has basic structure try: with open(DATA_FILE, 'r', encoding='utf-8') as f: - visitor_data_cache = json.load(f) + loaded_data = json.load(f) + if not isinstance(loaded_data, dict): + raise json.JSONDecodeError("Root is not a dictionary", "", 0) + visitor_data_cache = loaded_data 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 = {} + logging.warning(f"{DATA_FILE} not found locally. Initializing with default structure.") + visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} except json.JSONDecodeError: - logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.") - visitor_data_cache = {} + logging.error(f"Error decoding {DATA_FILE}. Initializing with default structure.") + visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} except Exception as e: logging.error(f"Unexpected error loading visitor data: {e}") - visitor_data_cache = {} + visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} + + # Ensure essential keys exist after loading or initializing + if 'users' not in visitor_data_cache: + visitor_data_cache['users'] = {} + if 'organization_info' not in visitor_data_cache: + visitor_data_cache['organization_info'] = DEFAULT_ORGANIZATION_INFO.copy() + return visitor_data_cache -def save_visitor_data(data): +def save_current_data(): with _data_lock: try: - visitor_data_cache.update(data) 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}.") + logging.info(f"Data successfully saved to {DATA_FILE}.") upload_data_to_hf_async() except Exception as e: - logging.error(f"Error saving visitor data: {e}") + logging.error(f"Error saving current data: {e}") def upload_data_to_hf(): if not HF_TOKEN_WRITE: @@ -115,21 +140,26 @@ def upload_data_to_hf(): 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')}" - ) + # No need for separate lock here if called from save_current_data which holds the lock, + # but if called directly (e.g. periodic backup), it's fine. + # However, to be safe and consistent, let's assume it should always grab the lock if it might write. + # For an async upload, it's better if it reads the file path rather than shared memory. + # The current design copies the file, so it's okay. + + 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 %Z')}" + ) logging.info("Bonus data successfully uploaded to Hugging Face.") except Exception as e: logging.error(f"Error uploading data to Hugging Face: {e}") @@ -145,7 +175,9 @@ def periodic_backup(): while True: time.sleep(3600) logging.info("Initiating periodic backup...") - upload_data_to_hf() + # This will save the current state from memory to file, then upload + save_current_data() + def verify_telegram_data(init_data_str): try: @@ -164,10 +196,12 @@ def verify_telegram_data(init_data_str): calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() if calculated_hash == received_hash: - 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}).") + auth_date_ts = int(parsed_data.get('auth_date', [0])[0]) + current_time_ts = int(time.time()) + if current_time_ts - auth_date_ts > 86400: # 24 hours + auth_date_str = datetime.fromtimestamp(auth_date_ts, BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S %Z') + current_time_str = datetime.fromtimestamp(current_time_ts, BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S %Z') + logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date_str}, Current: {current_time_str}).") return parsed_data, True else: logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}") @@ -203,7 +237,6 @@ TEMPLATE = """ --shadow-glow: 0 0 35px var(--shadow-color); --shadow-color-red: rgba(244, 67, 54, 0.15); --shadow-glow-red: 0 0 35px var(--shadow-color-red); - --link-color: var(--brand-yellow); } * { box-sizing: border-box; margin: 0; padding: 0; } html, body { @@ -243,9 +276,15 @@ TEMPLATE = """ .bonus-amount, .debt-amount { font-size: 3em; font-weight: 800; letter-spacing: -2px; line-height: 1; } .bonus-amount { color: var(--brand-yellow); } .debt-amount { color: var(--brand-red); } - .client-id-card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-m); display: flex; justify-content: space-between; align-items: center; } + .client-id-card { + background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-m); + display: flex; justify-content: space-between; align-items: center; + } .client-id-label { font-weight: 500; color: var(--text-secondary-color); } - .client-id-value { font-size: 1.3em; font-weight: 700; color: var(--brand-yellow); letter-spacing: 2px; background-color: rgba(255,193,7,0.1); padding: 4px 10px; border-radius: 8px; } + .client-id-value { + font-size: 1.3em; font-weight: 700; color: var(--brand-yellow); + letter-spacing: 2px; background-color: rgba(255,193,7,0.1); padding: 4px 10px; border-radius: 8px; + } .history-section { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-l); } .history-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); } .history-list { list-style: none; padding: 0; margin: 0; max-height: 35vh; overflow-y: auto; } @@ -253,34 +292,45 @@ TEMPLATE = """ .history-item:last-child { border-bottom: none; } .history-details { display: flex; flex-direction: column; } .history-description { font-size: 1em; font-weight: 500; } - .history-invoice-details-link { font-size: 0.8em; color: var(--link-color); cursor: pointer; text-decoration: underline; margin-top: 4px; } .history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; } .history-amount { font-size: 1.1em; font-weight: 700; } .history-amount.accrual { color: #4CAF50; } .history-amount.deduction { color: #F44336; } - .history-amount.invoice { color: var(--brand-yellow); } + .history-amount.invoice { color: var(--text-secondary-color); } + .view-invoice-btn { + background: rgba(255,255,255,0.1); color: var(--text-color); border: none; padding: 6px 10px; + border-radius: 6px; font-size: 0.8em; cursor: pointer; margin-left: 10px; + } .no-history { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; } - - .modal { + + .user-modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(3px); - align-items: center; justify-content: center; padding: var(--padding-m); + align-items: center; justify-content: center; padding: 15px; } - .modal-content { - background-color: var(--card-bg); color: var(--text-color); - padding: var(--padding-l); border-radius: var(--border-radius); - width: 100%; max-width: 500px; - box-shadow: 0 5px 25px rgba(0,0,0,0.3); + .user-modal-content { + background-color: var(--card-bg); color: var(--text-color); margin: auto; padding: 20px; + border: 1px solid rgba(255,255,255,0.2); border-radius: var(--border-radius); + width: auto; max-width: 90%; box-shadow: 0 5px 25px rgba(0,0,0,0.3); position: relative; } - .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255,255,255,0.1); } - .modal-title { font-size: 1.3em; font-weight: 700; } - .modal-close { font-size: 1.8em; font-weight: bold; color: var(--text-secondary-color); cursor: pointer; line-height: 1; } - .modal-body table { width: 100%; border-collapse: collapse; margin-top: 10px; } - .modal-body th, .modal-body td { text-align: left; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 0.9em; } - .modal-body th { font-weight: 600; } - .modal-body td.item-total, .modal-body td.grand-total { text-align: right; } - .modal-body .grand-total-row td { font-weight: bold; font-size: 1em; padding-top: 10px; border-top: 2px solid rgba(255,255,255,0.2); } + .user-modal-close { + color: var(--text-secondary-color); position: absolute; top: 10px; right: 15px; + font-size: 24px; font-weight: bold; cursor: pointer; + } + .user-modal-close:hover { color: var(--text-color); } + .user-modal-content h4 { margin-top: 0; margin-bottom: 15px; font-size: 1.2em; } + .user-modal-content p { margin-bottom: 8px; font-size: 0.9em; } + .user-modal-content ul { list-style: none; padding-left: 0; margin-bottom: 15px; } + .user-modal-content li { + padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 0.9em; + display: flex; justify-content: space-between; + } + .user-modal-content li:last-child { border-bottom: none; } + .user-modal-content strong { font-weight: 600; } + .item-name { flex-grow: 1; } + .item-qty-price { color: var(--text-secondary-color); margin-left:10px; white-space:nowrap; } + .item-total { font-weight: 500; margin-left:10px; white-space:nowrap; } @@ -315,9 +365,6 @@ TEMPLATE = """
Операций пока не было.
{% endif %} - - -