Spaces:
Sleeping
Sleeping
| from flask import Flask, jsonify, request, render_template_string, redirect, url_for | |
| import json | |
| import os | |
| import logging | |
| import threading | |
| import time | |
| from datetime import datetime, timezone | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError | |
| from dotenv import load_dotenv | |
| import requests | |
| import uuid | |
| import hashlib | |
| import hmac | |
| from urllib.parse import unquote, parse_qsl | |
| load_dotenv() | |
| app = Flask(__name__) | |
| app.secret_key = 'baaluu_telegram_mini_app_secret_final_version' | |
| CHAIN_FILE = 'blockchain.json' | |
| USERS_FILE = 'users.json' | |
| SYNC_FILES = [CHAIN_FILE, USERS_FILE] | |
| REPO_ID = "Kgshop/web3test" | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| BOT_TOKEN = "7908604005:AAEcIpTnBEB4F_-2l219mF6ssg5cloHxUW4" # Replace with your actual bot token | |
| DOWNLOAD_RETRIES = 3 | |
| DOWNLOAD_DELAY = 5 | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| class Blockchain: | |
| def __init__(self): | |
| self.chain = [] | |
| self.pending_contract_actions = [] | |
| self.difficulty = 4 | |
| def create_genesis_block(self): | |
| self.create_block(nonce=1, previous_hash='0') | |
| def create_block(self, nonce, previous_hash): | |
| block = { | |
| 'index': len(self.chain) + 1, | |
| 'timestamp': datetime.now(timezone.utc).isoformat(), | |
| 'contract_actions': self.pending_contract_actions, | |
| 'nonce': nonce, | |
| 'previous_hash': previous_hash, | |
| } | |
| self.pending_contract_actions = [] | |
| block['hash'] = self.hash_block(block) | |
| self.chain.append(block) | |
| return block | |
| def hash_block(block): | |
| block_string = json.dumps(block, sort_keys=True).encode() | |
| return hashlib.sha256(block_string).hexdigest() | |
| def add_contract_action(self, contract_id, user_id, action_type, details=None): | |
| action = { | |
| 'action_id': str(uuid.uuid4()), | |
| 'contract_id': contract_id, | |
| 'user_id': user_id, | |
| 'type': action_type, | |
| 'timestamp': datetime.now(timezone.utc).isoformat(), | |
| 'details': details if details is not None else {} | |
| } | |
| self.pending_contract_actions.append(action) | |
| return self.last_block['index'] + 1 if self.last_block else 1 | |
| def last_block(self): | |
| return self.chain[-1] if self.chain else None | |
| def proof_of_work(self, last_nonce): | |
| nonce = 0 | |
| while self.valid_proof(last_nonce, nonce) is False: | |
| nonce += 1 | |
| return nonce | |
| def valid_proof(self, last_nonce, nonce): | |
| guess = f'{last_nonce}{nonce}'.encode() | |
| guess_hash = hashlib.sha256(guess).hexdigest() | |
| return guess_hash[:self.difficulty] == '0' * self.difficulty | |
| def get_contract_details_from_chain(self, contract_id): | |
| contract_details = None | |
| actions = [] | |
| for block in self.chain: | |
| for action in block.get('contract_actions', []): | |
| if action.get('contract_id') == contract_id: | |
| actions.append(action) | |
| if action.get('type') == 'create': | |
| contract_details = action['details'] | |
| contract_details['contract_id'] = contract_id | |
| contract_details['created_at'] = action['timestamp'] | |
| return contract_details, sorted(actions, key=lambda x: x['timestamp']) | |
| def to_dict(self): | |
| return { | |
| 'chain': self.chain, | |
| 'pending_contract_actions': self.pending_contract_actions, | |
| 'difficulty': self.difficulty | |
| } | |
| def from_dict(cls, data): | |
| blockchain = cls.__new__(cls) | |
| blockchain.chain = data.get('chain', []) | |
| blockchain.pending_contract_actions = data.get('pending_contract_actions', []) | |
| blockchain.difficulty = data.get('difficulty', 4) | |
| if not blockchain.chain: | |
| blockchain.create_genesis_block() | |
| return blockchain | |
| def validate_telegram_data(init_data: str, bot_token: str) -> dict or None: | |
| try: | |
| parsed_data = dict(parse_qsl(init_data)) | |
| if 'hash' not in parsed_data: return None | |
| data_hash = parsed_data.pop('hash') | |
| data_check_string = "\n".join(f"{key}={value}" for key, value in sorted(parsed_data.items())) | |
| secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest() | |
| h = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256) | |
| if h.hexdigest() == data_hash: | |
| user_json = parsed_data.get('user') | |
| if user_json: | |
| return json.loads(user_json) | |
| except Exception: | |
| return None | |
| return None | |
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): | |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE | |
| if not token_to_use: | |
| logging.warning("HF_TOKEN not set. Download might fail for private repos.") | |
| files_to_download = [specific_file] if specific_file else SYNC_FILES | |
| logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...") | |
| all_successful = True | |
| for file_name in files_to_download: | |
| success = False | |
| for attempt in range(retries + 1): | |
| try: | |
| hf_hub_download( | |
| repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use, | |
| local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False | |
| ) | |
| success = True | |
| break | |
| except HfHubHTTPError as e: | |
| if e.response.status_code == 404: | |
| logging.warning(f"File {file_name} not found in repo (404). Will create new local file.") | |
| success = True | |
| break | |
| logging.error(f"HTTP error downloading {file_name}: {e}. Retrying...") | |
| except Exception as e: | |
| logging.error(f"Unexpected error downloading {file_name}: {e}. Retrying...", exc_info=True) | |
| if attempt < retries: time.sleep(delay) | |
| if not success: | |
| all_successful = False | |
| return all_successful | |
| def upload_db_to_hf(): | |
| if not HF_TOKEN_WRITE: | |
| logging.warning("HF_TOKEN (write) not set. Skipping upload.") | |
| return | |
| try: | |
| api = HfApi() | |
| for file_name in SYNC_FILES: | |
| if os.path.exists(file_name): | |
| api.upload_file( | |
| path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, | |
| repo_type="dataset", token=HF_TOKEN_WRITE, | |
| commit_message=f"Sync {file_name} {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}" | |
| ) | |
| except Exception as e: | |
| logging.error(f"General error during HF upload: {e}", exc_info=True) | |
| def periodic_backup(): | |
| while True: | |
| time.sleep(1800) | |
| logging.info("Starting periodic backup...") | |
| save_all_data() | |
| upload_db_to_hf() | |
| logging.info("Periodic backup finished.") | |
| def load_data(file_path, default_data_factory): | |
| try: | |
| with open(file_path, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| if download_db_from_hf(specific_file=file_path): | |
| try: | |
| with open(file_path, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| pass | |
| return default_data_factory() | |
| def save_all_data(): | |
| with open(CHAIN_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(blockchain.to_dict(), f, indent=2) | |
| with open(USERS_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(users_data, f, indent=2) | |
| logging.info("All data saved locally.") | |
| def create_block_from_pending_actions(): | |
| if not blockchain.pending_contract_actions: | |
| return None | |
| last_block = blockchain.last_block | |
| last_nonce = last_block['nonce'] | |
| nonce = blockchain.proof_of_work(last_nonce) | |
| previous_hash = blockchain.hash_block(last_block) | |
| new_block = blockchain.create_block(nonce, previous_hash) | |
| save_all_data() | |
| upload_db_to_hf() | |
| return new_block | |
| users_data = load_data(USERS_FILE, lambda: {'users': {}, 'usernames': {}}) | |
| blockchain = Blockchain.from_dict(load_data(CHAIN_FILE, lambda: Blockchain().to_dict())) | |
| MINI_APP_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <title>BaaluuContracts</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --tg-theme-bg-color: #f0f2f5; --tg-theme-text-color: #000000; --tg-theme-button-color: #007aff; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #999999; --tg-theme-secondary-bg-color: #ffffff;} | |
| @media (prefers-color-scheme: dark) { | |
| :root { --tg-theme-bg-color: #181c23; --tg-theme-text-color: #ffffff; --tg-theme-button-color: #67a8eb; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #a0a0a0; --tg-theme-secondary-bg-color: #242b33;} | |
| } | |
| html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } | |
| body { margin: 0; font-family: 'Inter', sans-serif; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: flex; flex-direction: column; min-height: 100vh; } | |
| .container { padding: 20px; padding-top: 30px; display: flex; flex-direction: column; flex-grow: 1; } | |
| #loader { display: flex; justify-content: center; align-items: center; height: 100vh; font-size: 1.2rem; color: var(--tg-theme-hint-color); } | |
| #app { display: none; flex-direction: column; flex-grow: 1; } | |
| .card { background-color: var(--tg-theme-secondary-bg-color); padding: 25px; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); } | |
| .card h2 { margin-top: 0; font-size: 1.2rem; font-weight: 600; color: var(--tg-theme-text-color); margin-bottom: 20px;} | |
| .form-group { margin-bottom: 18px; } | |
| label { display: block; font-size: 0.9rem; font-weight: 500; margin-bottom: 8px; color: var(--tg-theme-text-color);} | |
| .input-wrapper { position: relative; } | |
| .input-wrapper .icon { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: var(--tg-theme-hint-color); pointer-events: none; } | |
| input[type="text"], input[type="datetime-local"], textarea { width: 100%; padding: 14px; padding-left: 45px; border: 1px solid var(--tg-theme-bg-color); border-radius: 12px; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); font-size: 1rem; font-family: 'Inter', sans-serif; transition: border-color 0.2s; resize: vertical;} | |
| textarea { padding-left: 15px; } /* No icon for textarea */ | |
| input[type="text"]:focus, input[type="datetime-local"]:focus, textarea:focus { outline: none; border-color: var(--tg-theme-button-color); } | |
| .checkbox-group { display: flex; align-items: center; margin-top: 5px;} | |
| .checkbox-group input[type="checkbox"] { margin-right: 10px; width: auto; padding: 0; margin-left: 0;} | |
| .checkbox-group label { margin-bottom: 0; font-weight: 400; font-size: 1rem;} | |
| button { width: 100%; padding: 16px; border: none; border-radius: 12px; background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 10px; } | |
| button:disabled { background-color: var(--tg-theme-hint-color); opacity: 0.7; cursor: not-allowed; } | |
| .contract-list { display: flex; flex-direction: column; gap: 15px; } | |
| .contract-item { background-color: var(--tg-theme-bg-color); padding: 15px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; cursor: pointer; transition: background-color 0.2s;} | |
| .contract-item:hover { background-color: var(--tg-theme-hint-color, #999999)11; } | |
| .contract-info { flex-grow: 1; margin-right: 10px; } | |
| .contract-info h3 { margin: 0 0 5px 0; font-size: 1rem; font-weight: 600; color: var(--tg-theme-text-color);} | |
| .contract-info p { margin: 0; font-size: 0.9rem; color: var(--tg-theme-hint-color); } | |
| .contract-actions { display: flex; gap: 10px; } | |
| .contract-actions button { width: auto; padding: 8px 15px; font-size: 0.9rem; margin: 0; flex-shrink: 0; } | |
| .status-badge { display: inline-block; padding: 4px 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 500; margin-top: 5px; white-space: nowrap;} | |
| .status-pending { background-color: #ffc10733; color: #ffc107; } | |
| .status-signed_by_creator { background-color: #007bff33; color: #007bff; } /* Initial status after creation */ | |
| .status-signed_by_recipient { background-color: #28a74533; color: #28a745; } | |
| .status-rejected { background-color: #dc354533; color: #dc3545; } | |
| .status-unknown { background-color: #6c757d33; color: #6c757d; } | |
| .button-sign { background-color: #28a745; } | |
| .button-reject { background-color: #dc3545; } | |
| #notification { position: fixed; top: -100px; left: 20px; right: 20px; background-color: rgba(0,0,0,0.7); color: white; padding: 15px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); transition: top 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 1000; text-align: center; backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); } | |
| #notification.show { top: 20px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loader">Загрузка...</div> | |
| <div id="app" class="container"> | |
| <div class="card"> | |
| <h2>Создать Новый Договор</h2> | |
| <form id="createContractForm"> | |
| <div class="form-group"> | |
| <label for="contractTitle">Название договора</label> | |
| <input type="text" id="contractTitle" name="title" placeholder="Например: Аренда помещения" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="contractSubject">Предмет договора</label> | |
| <textarea id="contractSubject" name="subject" rows="4" placeholder="Подробное описание предмета..." required></textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label for="contractRecipient">Получатель (username Telegram)</label> | |
| <div class="input-wrapper"> | |
| <span class="icon">@</span> | |
| <input type="text" id="contractRecipient" name="recipient_username" placeholder="username" required> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Срок действия до</label> | |
| <input type="datetime-local" id="contractExpiration" name="expiration_datetime"> | |
| <div class="checkbox-group"> | |
| <input type="checkbox" id="noExpiration" name="no_expiration"> | |
| <label for="noExpiration">Без срока действия</label> | |
| </div> | |
| </div> | |
| <button type="submit" id="createButton">Создать Договор</button> | |
| </form> | |
| </div> | |
| <div class="card"> | |
| <h2>Мои Договоры (Созданные)</h2> | |
| <div id="createdContractsList" class="contract-list"> | |
| <p>Загрузка...</p> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Договоры Мне (Полученные)</h2> | |
| <div id="receivedContractsList" class="contract-list"> | |
| <p>Загрузка...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="notification"></div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const tg = window.Telegram.WebApp; | |
| tg.expand(); | |
| tg.ready(); | |
| tg.setHeaderColor(tg.themeParams.secondary_bg_color || '#ffffff'); | |
| const loader = document.getElementById('loader'); | |
| const app = document.getElementById('app'); | |
| const createdContractsList = document.getElementById('createdContractsList'); | |
| const receivedContractsList = document.getElementById('receivedContractsList'); | |
| const createContractForm = document.getElementById('createContractForm'); | |
| const createButton = document.getElementById('createButton'); | |
| const notification = document.getElementById('notification'); | |
| const expirationInput = document.getElementById('contractExpiration'); | |
| const noExpirationCheckbox = document.getElementById('noExpiration'); | |
| let currentUser = null; | |
| function showNotification(message, duration = 3000) { | |
| notification.textContent = message; | |
| notification.classList.add('show'); | |
| setTimeout(() => { notification.classList.remove('show'); }, duration); | |
| } | |
| async function login() { | |
| if (!tg.initData) { loader.textContent = "Ошибка аутентификации."; return; } | |
| try { | |
| const response = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: tg.initData }) }); | |
| if (!response.ok) { const err = await response.json(); throw new Error(err.error); } | |
| currentUser = await response.json(); | |
| updateUI(); | |
| } catch (error) { loader.textContent = `Ошибка: ${error.message}`; } | |
| } | |
| function getContractStatusText(status) { | |
| const texts = { | |
| 'signed_by_creator': 'Создан, ожидает', | |
| 'signed_by_recipient': 'Подписан', | |
| 'rejected': 'Отклонен' | |
| }; | |
| return texts[status] || 'Неизвестно'; | |
| } | |
| function getContractStatusClass(status) { | |
| const classes = { | |
| 'signed_by_creator': 'status-signed_by_creator', | |
| 'signed_by_recipient': 'status-signed_by_recipient', | |
| 'rejected': 'status-rejected' | |
| }; | |
| return classes[status] || 'status-unknown'; | |
| } | |
| function renderContracts(contracts, listElement, isCreated) { | |
| listElement.innerHTML = ''; | |
| if (!contracts || contracts.length === 0) { | |
| listElement.innerHTML = `<p>${isCreated ? 'У вас пока нет созданных договоров.' : 'У вас пока нет полученных договоров.'}</p>`; | |
| return; | |
| } | |
| contracts.forEach(contract => { | |
| const contractItem = document.createElement('div'); | |
| contractItem.classList.add('contract-item'); | |
| contractItem.setAttribute('data-contract-id', contract.id); | |
| const statusClass = getContractStatusClass(contract.status); | |
| const statusText = getContractStatusText(contract.status); | |
| const partnerInfo = isCreated ? `Получатель: @${contract.partner_username}` : `Отправитель: @${contract.partner_username}`; | |
| contractItem.innerHTML = ` | |
| <div class="contract-info"> | |
| <h3>${contract.title}</h3> | |
| <p>${partnerInfo}</p> | |
| <span class="status-badge ${statusClass}">${statusText}</span> | |
| </div> | |
| <div class="contract-actions"> | |
| ${!isCreated && contract.status === 'signed_by_creator' ? ` | |
| <button class="button-sign" data-action="sign">Подписать</button> | |
| <button class="button-reject" data-action="reject">Отклонить</button> | |
| ` : ''} | |
| </div> | |
| `; | |
| listElement.appendChild(contractItem); | |
| // Add click listener to open view page, but not on buttons | |
| contractItem.addEventListener('click', function(e) { | |
| if (e.target.tagName !== 'BUTTON') { | |
| window.location.href = '/contracts/' + contract.id + '/view?initData=' + encodeURIComponent(tg.initData); | |
| } | |
| }); | |
| }); | |
| } | |
| function updateUI() { | |
| if (!currentUser) return; | |
| renderContracts(currentUser.created_contracts || [], createdContractsList, true); | |
| renderContracts(currentUser.received_contracts || [], receivedContractsList, false); | |
| loader.style.display = 'none'; | |
| app.style.display = 'flex'; | |
| } | |
| // Handle expiration date/time input state | |
| noExpirationCheckbox.addEventListener('change', function() { | |
| expirationInput.disabled = this.checked; | |
| if (this.checked) { | |
| expirationInput.value = ''; // Clear value when unchecked | |
| } | |
| }); | |
| createContractForm.addEventListener('submit', async function(e) { | |
| e.preventDefault(); | |
| const recipientUsername = document.getElementById('contractRecipient').value.trim(); | |
| if (currentUser.username && recipientUsername.toLowerCase() === currentUser.username.toLowerCase()) { | |
| showNotification("Нельзя создать договор самому себе."); | |
| return; | |
| } | |
| tg.MainButton.showProgress(); | |
| createButton.disabled = true; | |
| const formData = new FormData(createContractForm); | |
| const noExpiration = formData.get('no_expiration') === 'on'; | |
| const expirationDatetime = formData.get('expiration_datetime'); | |
| if (!noExpiration && !expirationDatetime) { | |
| showNotification("Пожалуйста, укажите срок действия или выберите 'Без срока действия'."); | |
| tg.MainButton.hideProgress(); | |
| createButton.disabled = false; | |
| return; | |
| } | |
| if (!noExpiration) { | |
| // Basic check if date is in the past | |
| const selectedDate = new Date(expirationDatetime); | |
| const now = new Date(); | |
| if (selectedDate < now) { | |
| showNotification("Срок действия не может быть в прошлом."); | |
| tg.MainButton.hideProgress(); | |
| createButton.disabled = false; | |
| return; | |
| } | |
| } | |
| const data = { | |
| initData: tg.initData, | |
| title: formData.get('title'), | |
| subject: formData.get('subject'), | |
| recipient_username: recipientUsername, | |
| expiration_datetime: noExpiration ? null : expirationDatetime | |
| }; | |
| try { | |
| const response = await fetch('/contracts/new', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| const result = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(result.error || 'Ошибка при создании договора'); | |
| } | |
| tg.HapticFeedback.notificationOccurred('success'); | |
| showNotification('Договор успешно создан и ожидает подписи получателя!'); | |
| createContractForm.reset(); | |
| expirationInput.disabled = false; // Reset state | |
| noExpirationCheckbox.checked = false; // Reset state | |
| setTimeout(login, 500); // Reload user data to update lists | |
| } catch (error) { | |
| tg.HapticFeedback.notificationOccurred('error'); | |
| showNotification(`Ошибка: ${error.message}`); | |
| } finally { | |
| tg.MainButton.hideProgress(); | |
| createButton.disabled = false; | |
| } | |
| }); | |
| receivedContractsList.addEventListener('click', async function(e) { | |
| const target = e.target; | |
| if (target.tagName === 'BUTTON') { | |
| e.stopPropagation(); // Prevent opening contract view | |
| const action = target.getAttribute('data-action'); | |
| const contractItem = target.closest('.contract-item'); | |
| const contractId = contractItem.getAttribute('data-contract-id'); | |
| if (!action || !contractId) return; | |
| tg.MainButton.showProgress(); | |
| contractItem.querySelectorAll('button').forEach(btn => btn.disabled = true); | |
| try { | |
| const response = await fetch(`/contracts/${contractId}/${action}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ initData: tg.initData }) | |
| }); | |
| const result = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(result.error || `Ошибка при выполнении действия "${action}"`); | |
| } | |
| tg.HapticFeedback.notificationOccurred('success'); | |
| showNotification(`Договор успешно ${action === 'sign' ? 'подписан' : 'отклонен'}!`); | |
| setTimeout(login, 500); // Reload user data to update lists | |
| } catch (error) { | |
| tg.HapticFeedback.notificationOccurred('error'); | |
| showNotification(`Ошибка: ${error.message}`); | |
| } finally { | |
| tg.MainButton.hideProgress(); | |
| } | |
| } | |
| }); | |
| login(); // Initial login on load | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| VIEW_CONTRACT_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <title>{{ contract.title }}</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --tg-theme-bg-color: #f0f2f5; --tg-theme-text-color: #000000; --tg-theme-button-color: #007aff; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #999999; --tg-theme-secondary-bg-color: #ffffff;} | |
| @media (prefers-color-scheme: dark) { | |
| :root { --tg-theme-bg-color: #181c23; --tg-theme-text-color: #ffffff; --tg-theme-button-color: #67a8eb; --tg-theme-button-text-color: #ffffff; --tg-theme-hint-color: #a0a0a0; --tg-theme-secondary-bg-color: #242b33;} | |
| } | |
| html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } | |
| body { margin: 0; font-family: 'Inter', sans-serif; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } | |
| .container { padding: 20px; padding-top: 30px; } | |
| h1 { font-size: 1.8rem; margin-top: 0; margin-bottom: 10px; color: var(--tg-theme-text-color); } | |
| .subtitle { font-size: 1rem; color: var(--tg-theme-hint-color); margin-bottom: 20px; } | |
| .card { background-color: var(--tg-theme-secondary-bg-color); padding: 25px; border-radius: 20px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); } | |
| .card h2 { font-size: 1.2rem; font-weight: 600; color: var(--tg-theme-text-color); margin-top: 0; margin-bottom: 15px;} | |
| .details p { margin: 8px 0; font-size: 1rem; line-height: 1.5; color: var(--tg-theme-text-color);} | |
| .details strong { color: var(--tg-theme-text-color); font-weight: 500;} | |
| .subject-content { font-family: 'Roboto Mono', monospace; white-space: pre-wrap; word-break: break-word; background-color: var(--tg-theme-bg-color); padding: 15px; border-radius: 12px; margin-top: 15px; font-size: 0.9rem;} | |
| .status-badge { display: inline-block; padding: 6px 12px; border-radius: 12px; font-size: 0.9rem; font-weight: 500; margin-top: 10px;} | |
| .status-signed_by_creator { background-color: #007bff33; color: #007bff; } | |
| .status-signed_by_recipient { background-color: #28a74533; color: #28a745; } | |
| .status-rejected { background-color: #dc354533; color: #dc3545; } | |
| .status-unknown { background-color: #6c757d33; color: #6c757d; } | |
| .actions-history { margin-top: 25px; border-top: 1px solid var(--tg-theme-bg-color); padding-top: 20px; } | |
| .actions-history h3 { font-size: 1.1rem; margin-bottom: 15px; } | |
| .action-item { background-color: var(--tg-theme-bg-color); padding: 12px; border-radius: 8px; margin-bottom: 10px; font-size: 0.9rem; color: var(--tg-theme-text-color);} | |
| .action-item strong { font-weight: 500; } | |
| .action-item span { color: var(--tg-theme-hint-color); font-size: 0.85rem; } | |
| .back-button-container { margin-top: 30px; } | |
| .back-button { display: block; width: 100%; padding: 16px; border: none; border-radius: 12px; background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); font-size: 1.1rem; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; } | |
| .expiry-info { margin-top: 10px; font-size: 0.95rem; color: var(--tg-theme-hint-color); } | |
| .expiry-info strong { color: var(--tg-theme-text-color); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>{{ contract.title }}</h1> | |
| <p class="subtitle">Договор ID: {{ contract.contract_id[:8] }}...</p> | |
| <div class="card"> | |
| <h2>Участники</h2> | |
| <div class="details"> | |
| <p><strong>Создатель:</strong> @{{ creator_username }}</p> | |
| <p><strong>Получатель:</strong> @{{ recipient_username }}</p> | |
| <p><strong>Статус:</strong> <span class="status-badge {{ get_status_class(current_status) }}">{{ get_status_text(current_status) }}</span></p> | |
| {% if expiration_display %} | |
| <p class="expiry-info"><strong>Срок действия до:</strong> {{ expiration_display }}</p> | |
| {% else %} | |
| <p class="expiry-info"><strong>Срок действия:</strong> Без срока</p> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Предмет договора</h2> | |
| <div class="subject-content">{{ contract.subject }}</div> | |
| </div> | |
| <div class="card actions-history"> | |
| <h3>История действий</h3> | |
| {% if actions %} | |
| {% for action in actions %} | |
| <div class="action-item"> | |
| <strong>{{ action.type|capitalize }}:</strong> {{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }} | |
| <span>({{ action.timestamp.replace('T', ' ')[:19] }} UTC)</span> | |
| </div> | |
| {% endfor %} | |
| {% else %} | |
| <p>Нет зарегистрированных действий по этому договору (ошибка).</p> | |
| {% endif %} | |
| </div> | |
| <div class="back-button-container"> | |
| <a href="/" class="back-button">Вернуться к договорам</a> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| const tg = window.Telegram.WebApp; | |
| tg.ready(); | |
| tg.expand(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| ADMIN_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Админ-панель BaaluuChain</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500&family=Inter:wght@400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; background-color: #eef0f2; color: #333; } | |
| .container { max-width: 1200px; margin: 20px auto; padding: 20px; } | |
| h1 { text-align: center; color: #1d2633; margin-bottom: 30px; } | |
| .chain { display: flex; flex-direction: column; gap: 25px; } | |
| .block { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 5px 15px rgba(0,0,0,0.08); border-left: 6px solid #007aff; } | |
| .block:first-child { border-left-color: #28a745; } /* Genesis block */ | |
| .block-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e9ecef; padding-bottom: 10px; margin-bottom: 15px; } | |
| .block-header h2 { margin: 0; font-size: 1.3rem; font-weight: 600; } | |
| .block-header .timestamp { font-family: 'Roboto Mono', monospace; font-size: 0.9rem; color: #6c757d; } | |
| .block-details { font-family: 'Roboto Mono', monospace; font-size: 0.95rem; line-height: 1.8; word-break: break-all; } | |
| .block-details p { margin: 8px 0; } | |
| .block-details strong { color: #343a40; font-weight: 500;} | |
| .actions { margin-top: 20px; padding-top: 20px; border-top: 1px dashed #ced4da; } | |
| .actions h3 { font-family: 'Inter', sans-serif; font-size: 1.1rem; margin-top: 0; margin-bottom: 15px; } | |
| .action { background-color: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #6c757d; } | |
| .action.type-create { border-left-color: #007bff; } | |
| .action.type-sign { border-left-color: #28a745; } | |
| .action.type-reject { border-left-color: #dc3545; } | |
| .action-details { font-size: 0.9rem; } | |
| .action-details p { margin: 4px 0; } | |
| .action-details p strong { font-weight: 500;} | |
| .subject-snippet { font-family: 'Roboto Mono', monospace; font-size: 0.85rem; color: #555; margin-top: 5px; word-break: break-word; white-space: pre-wrap;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Обозреватель сети BaaluuChain (Договоры)</h1> | |
| <div class="chain"> | |
| {% for block in chain|reverse %} | |
| <div class="block"> | |
| <div class="block-header"> | |
| <h2>Блок #{{ block.index }}</h2> | |
| <span class="timestamp">{{ block.timestamp.replace('T', ' ')[:19] }} UTC</span> | |
| </div> | |
| <div class="block-details"> | |
| <p><strong>Hash:</strong> {{ block.hash }}</p> | |
| <p><strong>Prev Hash:</strong> {{ block.previous_hash }}</p> | |
| <p><strong>Nonce:</strong> {{ block.nonce }}</p> | |
| </div> | |
| <div class="actions"> | |
| <h3>Действия с договорами в блоке ({{ block.contract_actions|length }})</h3> | |
| {% for action in block.contract_actions %} | |
| <div class="action type-{{ action.type }}"> | |
| <div class="action-details"> | |
| <p><strong>Действие:</strong> {{ action.type|capitalize }}</p> | |
| <p><strong>Договор ID:</strong> {{ action.contract_id }}</p> | |
| <p><strong>Пользователь ID:</strong> {{ action.user_id }} ({{ users_data.get(action.user_id, {}).get('username', 'Неизвестно') }})</p> | |
| {% if action.type == 'create' %} | |
| {% if action.details %} | |
| <p><strong>Название:</strong> {{ action.details.get('title', 'N/A') }}</p> | |
| <p><strong>Получатель ID:</strong> {{ action.details.get('recipient_id', 'N/A') }} ({{ users_data.get(action.details.get('recipient_id'), {}).get('username', 'Неизвестно') }})</p> | |
| <p><strong>Срок до:</strong> {{ action.details.get('expiration_datetime', 'Без срока') }}</p> | |
| <p class="subject-snippet"><strong>Предмет:</strong> {{ action.details.get('subject', 'N/A')|truncate(150, True) }}</p> | |
| {% endif %} | |
| {% endif %} | |
| </div> | |
| </div> | |
| {% else %} | |
| <p>Нет действий с договорами в этом блоке.</p> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| # Helper functions for templates | |
| def get_username_by_id(user_id): | |
| return users_data['users'].get(user_id, {}).get('username', 'Неизвестно') | |
| def get_status_text(status): | |
| texts = { | |
| 'signed_by_creator': 'Создан, ожидает', | |
| 'signed_by_recipient': 'Подписан', | |
| 'rejected': 'Отклонен' | |
| } | |
| return texts.get(status, 'Неизвестно') | |
| def get_status_class(status): | |
| classes = { | |
| 'signed_by_creator': 'status-signed_by_creator', | |
| 'signed_by_recipient': 'status-signed_by_recipient', | |
| 'rejected': 'status-rejected' | |
| } | |
| return classes.get(status, 'status-unknown') | |
| def format_expiration_datetime(iso_string): | |
| if not iso_string: | |
| return None | |
| try: | |
| dt_obj = datetime.fromisoformat(iso_string.replace('Z', '+00:00')) # Handle Z timezone format | |
| return dt_obj.strftime('%Y-%m-%d %H:%M UTC') | |
| except ValueError: | |
| return iso_string # Return original if formatting fails | |
| def index(): | |
| return render_template_string(MINI_APP_TEMPLATE) | |
| def admin_panel(): | |
| return render_template_string(ADMIN_TEMPLATE, chain=blockchain.chain, users_data=users_data['users']) | |
| def login(): | |
| data = request.json | |
| user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) | |
| if not user_info: | |
| return jsonify({"error": "Неверная аутентификация Telegram"}), 403 | |
| user_id = str(user_info['id']) | |
| username = user_info.get('username') | |
| first_name = user_info.get('first_name', '') | |
| last_name = user_info.get('last_name', '') | |
| if user_id not in users_data['users']: | |
| logging.info(f"Новый пользователь: ID {user_id}, @{username or 'N/A'}") | |
| users_data['users'][user_id] = { | |
| "username": username, | |
| "first_name": first_name, | |
| "last_name": last_name, | |
| "created_contracts": [], | |
| "received_contracts": [] | |
| } | |
| if username: | |
| users_data['usernames'][username.lower()] = user_id | |
| save_all_data() | |
| upload_db_to_hf() | |
| else: | |
| current_user_data = users_data['users'][user_id] | |
| # Update username/name if changed | |
| if current_user_data.get('username') != username: | |
| old_username = current_user_data.get('username') | |
| if old_username and old_username.lower() in users_data['usernames']: | |
| del users_data['usernames'][old_username.lower()] | |
| current_user_data['username'] = username | |
| if username: users_data['usernames'][username.lower()] = user_id | |
| # Note: save_all_data/upload happens after updating both lists below | |
| if current_user_data.get('first_name') != first_name: | |
| current_user_data['first_name'] = first_name | |
| if current_user_data.get('last_name') != last_name: | |
| current_user_data['last_name'] = last_name | |
| user = users_data['users'][user_id] | |
| # For the UI, we need partner username and update status from chain if necessary | |
| def enhance_contracts_list(contracts_list): | |
| enhanced_list = [] | |
| for contract_summary in contracts_list: | |
| partner_id = contract_summary.get('partner_id') | |
| partner_username = users_data['users'].get(partner_id, {}).get('username', 'Неизвестно') | |
| # Check chain for latest status | |
| contract_details, actions = blockchain.get_contract_details_from_chain(contract_summary['id']) | |
| current_status = 'signed_by_creator' # Default after creation | |
| for action in actions: | |
| if action['type'] in ['sign', 'reject'] and action['user_id'] == contract_summary['partner_id']: | |
| if action['type'] == 'sign': | |
| current_status = 'signed_by_recipient' | |
| elif action['type'] == 'reject': | |
| current_status = 'rejected' | |
| # Update status in users_data if it differs from chain | |
| if contract_summary.get('status') != current_status: | |
| contract_summary['status'] = current_status | |
| # Mark for saving | |
| enhanced_list.append({**contract_summary, 'partner_username': partner_username}) | |
| return enhanced_list | |
| # Update user data structure in memory with latest statuses from chain | |
| user['created_contracts'] = enhance_contracts_list(user.get('created_contracts', [])) | |
| user['received_contracts'] = enhance_contracts_list(user.get('received_contracts', [])) | |
| # Save updated statuses and user info (like username change) | |
| save_all_data() | |
| upload_db_to_hf() | |
| return jsonify({ | |
| "user_id": user_id, | |
| "username": user.get('username'), | |
| "first_name": user.get('first_name'), | |
| "last_name": user.get('last_name'), | |
| "created_contracts": user['created_contracts'], | |
| "received_contracts": user['received_contracts'] | |
| }) | |
| def new_contract(): | |
| data = request.json | |
| user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) | |
| if not user_info: return jsonify({"error": "Неверная аутентификация"}), 403 | |
| creator_id = str(user_info['id']) | |
| title = data.get('title', '').strip() | |
| subject = data.get('subject', '').strip() | |
| recipient_username = data.get('recipient_username', '').strip().lstrip('@') | |
| expiration_datetime_str = data.get('expiration_datetime') | |
| if not all([title, subject, recipient_username]): | |
| return jsonify({"error": "Все обязательные поля (название, предмет, получатель) должны быть заполнены"}), 400 | |
| if '@' in recipient_username: | |
| recipient_username = recipient_username.lstrip('@') | |
| recipient_id = users_data['usernames'].get(recipient_username.lower()) | |
| if not recipient_id or recipient_id not in users_data['users']: | |
| return jsonify({"error": f"Пользователь с username @{recipient_username} не найден"}), 404 | |
| if creator_id == recipient_id: | |
| return jsonify({"error": "Нельзя создать договор самому себе"}), 400 | |
| # Validate and format expiration date | |
| expiration_iso = None | |
| if expiration_datetime_str: | |
| try: | |
| # Assume datetime-local format yyyy-MM-ddThh:mm | |
| # Convert to UTC and ISO format | |
| local_dt = datetime.fromisoformat(expiration_datetime_str) | |
| # Assuming the input is local time, convert to UTC | |
| # Note: This is a simplification. A real app should handle timezones properly. | |
| # For now, we'll just store it as provided or simple ISO. | |
| # Let's just store the string provided by datetime-local input | |
| expiration_iso = expiration_datetime_str # Store as is for simplicity or convert if needed | |
| except ValueError: | |
| return jsonify({"error": "Неверный формат даты/времени срока действия"}), 400 | |
| # Basic check against current time | |
| try: | |
| # Create a datetime object from the input string for comparison | |
| input_dt = datetime.fromisoformat(expiration_datetime_str) | |
| # Get current time in a timezone-aware format, or naive if comparison is naive | |
| # Comparing naive datetimes: | |
| if input_dt < datetime.now(): | |
| return jsonify({"error": "Срок действия не может быть в прошлом"}), 400 | |
| except ValueError: | |
| pass # Already caught invalid format above | |
| contract_id = str(uuid.uuid4()) | |
| # 1. Record creation action in blockchain pending list | |
| blockchain.add_contract_action( | |
| contract_id=contract_id, | |
| user_id=creator_id, # Creator performs the 'create' action | |
| action_type='create', | |
| details={ | |
| 'title': title, | |
| 'subject': subject, | |
| 'recipient_id': recipient_id, | |
| 'creator_id': creator_id, | |
| 'expiration_datetime': expiration_iso # Store expiration date/time | |
| } | |
| ) | |
| # 2. Update users_data with contract summary | |
| creator_user_data = users_data['users'][creator_id] | |
| recipient_user_data = users_data['users'][recipient_id] | |
| # Initial status is signed by creator, pending recipient | |
| contract_summary_for_creator = { | |
| 'id': contract_id, | |
| 'title': title, | |
| 'status': 'signed_by_creator', # Creator automatically signs on creation | |
| 'partner_id': recipient_id | |
| # Expiration is not needed in summary, retrieved on view | |
| } | |
| contract_summary_for_recipient = { | |
| 'id': contract_id, | |
| 'title': title, | |
| 'status': 'signed_by_creator', # Status is pending recipient signature | |
| 'partner_id': creator_id | |
| } | |
| creator_user_data['created_contracts'].append(contract_summary_for_creator) | |
| recipient_user_data['received_contracts'].append(contract_summary_for_recipient) | |
| # 3. Mine block, save and upload | |
| create_block_from_pending_actions() | |
| return jsonify({"message": "Договор успешно создан и ожидает подписи получателя", "contract_id": contract_id}), 201 | |
| def contract_action(contract_id, action_type): | |
| data = request.json | |
| user_info = validate_telegram_data(data.get('initData'), BOT_TOKEN) | |
| if not user_info: return jsonify({"error": "Неверная аутентификация"}), 403 | |
| user_id = str(user_info['id']) | |
| valid_actions = ['sign', 'reject'] | |
| if action_type not in valid_actions: | |
| return jsonify({"error": "Неверное действие"}), 400 | |
| user_data = users_data['users'].get(user_id) | |
| if not user_data: | |
| return jsonify({"error": "Пользователь не найден"}), 404 | |
| # Find the contract in user's received contracts list and check status | |
| is_recipient = False | |
| contract_summary_index = -1 | |
| for i, c in enumerate(user_data.get('received_contracts', [])): | |
| if c.get('id') == contract_id: | |
| is_recipient = True | |
| contract_summary_index = i | |
| break | |
| if not is_recipient: | |
| # Could be the creator trying to sign/reject their own contract? Prevent this. | |
| # Or maybe the contract simply doesn't exist or isn't for this user. | |
| return jsonify({"error": "Действие невозможно для этого договора или вы не являетесь его получателем"}), 403 | |
| contract_summary = user_data['received_contracts'][contract_summary_index] | |
| # Check if the contract is in a state where this action is allowed (must be pending recipient signature) | |
| if contract_summary.get('status') != 'signed_by_creator': | |
| return jsonify({"error": f"Договор уже имеет статус '{get_status_text(contract_summary.get('status'))}'"}), 400 | |
| # Record action in blockchain pending list | |
| blockchain.add_contract_action( | |
| contract_id=contract_id, | |
| user_id=user_id, # Recipient performs the action | |
| action_type=action_type, | |
| details={} # No extra details needed for sign/reject | |
| ) | |
| # Update contract status in users_data for both parties | |
| new_status = 'signed_by_recipient' if action_type == 'sign' else 'rejected' | |
| creator_id = contract_summary.get('partner_id') # Partner for recipient is the creator | |
| # Update recipient's list | |
| user_data['received_contracts'][contract_summary_index]['status'] = new_status | |
| # Update creator's list | |
| if creator_id and creator_id in users_data['users']: | |
| creator_user_data = users_data['users'][creator_id] | |
| for i, c in enumerate(creator_user_data.get('created_contracts', [])): | |
| if c.get('id') == contract_id: | |
| creator_user_data['created_contracts'][i]['status'] = new_status | |
| break | |
| # Mine block, save and upload | |
| create_block_from_pending_actions() | |
| action_message = "подписан" if action_type == 'sign' else "отклонен" | |
| return jsonify({"message": f"Договор успешно {action_message}"}), 200 | |
| def view_contract(contract_id): | |
| init_data = request.args.get('initData') | |
| user_info = validate_telegram_data(init_data, BOT_TOKEN) | |
| # Allow viewing even without initData if the contract is public? | |
| # For this implementation, we assume users only view contracts they are a part of. | |
| # So authentication is required. | |
| if not user_info: | |
| return "Неверная аутентификация", 403 | |
| user_id = str(user_info['id']) | |
| # Check if the user is a party to this contract (creator or recipient) | |
| user_is_party = False | |
| if user_id in users_data['users']: | |
| user_contracts = users_data['users'][user_id].get('created_contracts', []) + users_data['users'][user_id].get('received_contracts', []) | |
| for contract_summary in user_contracts: | |
| if contract_summary.get('id') == contract_id: | |
| user_is_party = True | |
| break | |
| if not user_is_party: | |
| return "Договор не найден или у вас нет доступа", 404 | |
| # Get contract details and actions from the blockchain | |
| contract_details, actions = blockchain.get_contract_details_from_chain(contract_id) | |
| if not contract_details: | |
| return "Договор не найден в блокчейне", 404 | |
| creator_id = contract_details.get('creator_id') | |
| recipient_id = contract_details.get('recipient_id') | |
| creator_username = get_username_by_id(creator_id) | |
| recipient_username = get_username_by_id(recipient_id) | |
| # Determine current status from actions | |
| current_status = 'signed_by_creator' # Initial status | |
| for action in actions: | |
| if action['type'] in ['sign', 'reject'] and action['user_id'] == recipient_id: | |
| if action['type'] == 'sign': | |
| current_status = 'signed_by_recipient' | |
| elif action['type'] == 'reject': | |
| current_status = 'rejected' | |
| break # Once signed or rejected by recipient, status is final | |
| expiration_datetime = contract_details.get('expiration_datetime') | |
| expiration_display = format_expiration_datetime(expiration_datetime) | |
| return render_template_string( | |
| VIEW_CONTRACT_TEMPLATE, | |
| contract=contract_details, | |
| creator_username=creator_username, | |
| recipient_username=recipient_username, | |
| actions=actions, | |
| current_status=current_status, | |
| users_data=users_data['users'], # Pass users_data for username lookup in template | |
| get_status_text=get_status_text, # Pass helper functions | |
| get_status_class=get_status_class, | |
| expiration_display=expiration_display | |
| ) | |
| if __name__ == '__main__': | |
| logging.info("Application starting up.") | |
| if HF_TOKEN_WRITE: | |
| threading.Thread(target=periodic_backup, daemon=True).start() | |
| logging.info("Periodic backup thread started.") | |
| else: | |
| logging.warning("Periodic backup will NOT run (HF_TOKEN for writing not set).") | |
| port = int(os.environ.get('PORT', 7860)) | |
| logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}") | |
| app.run(host='0.0.0.0', port=port) |