Kgshop commited on
Commit
7ea0e3b
·
verified ·
1 Parent(s): dd33819

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +483 -490
app.py CHANGED
@@ -2,26 +2,25 @@
2
  # -*- coding: utf-8 -*-
3
 
4
  import os
5
- from flask import Flask, request, Response, render_template_string, jsonify
6
  import hmac
7
  import hashlib
8
  import json
9
- from urllib.parse import unquote, parse_qs
10
  import time
11
  from datetime import datetime
12
  import logging
13
  import threading
14
  from huggingface_hub import HfApi, hf_hub_download
15
  from huggingface_hub.utils import RepositoryNotFoundError
16
- import uuid
17
 
18
  BOT_TOKEN = os.getenv("BOT_TOKEN", "7531615056:AAFL7Lp1kc-sAshoiM0tfez5-7wea26GXYU")
19
  HOST = '0.0.0.0'
20
  PORT = 7860
21
- DATA_FILE = 'druzhba_data.json'
22
 
23
  REPO_ID = "flpolprojects/druzhbabase"
24
- HF_DATA_FILE_PATH = "druzhba_data.json"
25
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
26
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
27
 
@@ -106,9 +105,11 @@ def upload_data_to_hf():
106
  try:
107
  api = HfApi()
108
  with _data_lock:
109
- if not os.path.getsize(DATA_FILE) > 0:
 
110
  logging.warning(f"{DATA_FILE} is empty. Skipping upload.")
111
  return
 
112
  logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...")
113
  api.upload_file(
114
  path_or_fileobj=DATA_FILE,
@@ -152,6 +153,10 @@ def verify_telegram_data(init_data_str):
152
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
153
 
154
  if calculated_hash == received_hash:
 
 
 
 
155
  return parsed_data, True
156
  else:
157
  logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}")
@@ -160,43 +165,55 @@ def verify_telegram_data(init_data_str):
160
  logging.error(f"Error verifying Telegram data: {e}")
161
  return None, False
162
 
163
- USER_TEMPLATE = """
164
  <!DOCTYPE html>
165
  <html lang="ru">
166
  <head>
167
  <meta charset="UTF-8">
168
  <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover">
169
- <title>Druzhba Bonus</title>
170
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
171
  <link rel="preconnect" href="https://fonts.googleapis.com">
172
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
173
- <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
174
  <style>
175
  :root {
176
- --druzhba-yellow: {{ theme.button_color | default('#ffc700') }};
177
- --druzhba-black: {{ theme.bg_color | default('#121212') }};
178
- --druzhba-text: {{ theme.text_color | default('#ffffff') }};
179
- --druzhba-text-secondary: {{ theme.hint_color | default('#8a8a8e') }};
180
- --druzhba-card-bg: {{ theme.secondary_bg_color | default('#1c1c1e') }};
181
-
182
- --border-radius: 16px;
183
- --padding-m: 16px;
184
- --padding-l: 24px;
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
  * { box-sizing: border-box; margin: 0; padding: 0; }
187
-
188
- body {
189
- font-family: 'Manrope', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
190
- background-color: var(--druzhba-black);
191
- color: var(--druzhba-text);
192
  padding: var(--padding-m);
 
193
  -webkit-font-smoothing: antialiased;
194
  -moz-osx-font-smoothing: grayscale;
195
  visibility: hidden;
196
  min-height: 100vh;
197
  }
198
  .container {
199
- max-width: 600px;
200
  margin: 0 auto;
201
  display: flex;
202
  flex-direction: column;
@@ -208,97 +225,96 @@ USER_TEMPLATE = """
208
  }
209
  .logo {
210
  font-size: 2em;
211
- font-weight: 800;
212
- color: var(--druzhba-yellow);
213
  }
214
- .user-greeting {
215
- margin-top: 8px;
216
  font-size: 1.1em;
217
- font-weight: 500;
218
- color: var(--druzhba-text-secondary);
219
  }
220
- .balance-card {
221
- background: linear-gradient(135deg, var(--druzhba-card-bg), #2c2c2e);
222
- border-radius: var(--border-radius);
223
  padding: var(--padding-l);
224
  text-align: center;
225
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
226
- border: 1px solid rgba(255, 255, 255, 0.1);
227
  }
228
- .balance-title {
229
- font-size: 1.1em;
230
  font-weight: 500;
231
- color: var(--druzhba-text-secondary);
232
- margin-bottom: 8px;
233
- }
234
- .balance-amount {
235
- font-size: 3em;
236
- font-weight: 800;
237
- color: var(--druzhba-yellow);
238
- line-height: 1.2;
239
  }
240
- .loader {
241
- font-size: 1.5em;
242
- font-weight: 600;
243
- color: var(--druzhba-text-secondary);
244
- animation: pulse 1.5s infinite ease-in-out;
245
  }
246
- @keyframes pulse {
247
- 0% { opacity: 1; }
248
- 50% { opacity: 0.5; }
249
- 100% { opacity: 1; }
250
  }
251
  .history-section {
252
- background-color: var(--druzhba-card-bg);
253
- border-radius: var(--border-radius);
254
  padding: var(--padding-l);
 
 
255
  }
256
  .history-title {
257
  font-size: 1.5em;
258
- font-weight: 700;
259
  margin-bottom: var(--padding-m);
 
 
260
  }
261
- .transaction-list {
262
  list-style: none;
263
  padding: 0;
264
- display: flex;
265
- flex-direction: column;
266
- gap: var(--padding-m);
267
  }
268
- .transaction-item {
269
  display: flex;
270
  justify-content: space-between;
271
  align-items: center;
272
- padding-bottom: var(--padding-m);
273
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
274
  }
275
- .transaction-item:last-child {
276
  border-bottom: none;
277
- padding-bottom: 0;
278
  }
279
- .transaction-details {
280
  display: flex;
281
  flex-direction: column;
282
- gap: 4px;
283
  }
284
- .transaction-description {
285
- font-weight: 600;
286
  font-size: 1em;
 
287
  }
288
- .transaction-date {
289
  font-size: 0.85em;
290
- color: var(--druzhba-text-secondary);
 
291
  }
292
- .transaction-amount {
293
- font-weight: 700;
294
  font-size: 1.2em;
 
 
 
 
 
 
 
295
  }
296
- .transaction-amount.accrual { color: #34c759; }
297
- .transaction-amount.deduction { color: #ff3b30; }
298
  .no-history {
299
  text-align: center;
300
- padding: 32px 0;
301
- color: var(--druzhba-text-secondary);
302
  }
303
  </style>
304
  </head>
@@ -306,19 +322,33 @@ USER_TEMPLATE = """
306
  <div class="container">
307
  <header class="header">
308
  <div class="logo">DRUZHBA</div>
309
- <div id="user-greeting" class="user-greeting">Загрузка...</div>
310
  </header>
311
 
312
- <section class="balance-card">
313
- <div class="balance-title">Ваш бонусный баланс</div>
314
- <div id="balance-amount" class="balance-amount loader">...</div>
315
  </section>
316
-
317
  <section class="history-section">
318
  <h2 class="history-title">История операций</h2>
319
- <ul id="transaction-list" class="transaction-list">
320
- <li class="no-history">История операций пуста</li>
321
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  </section>
323
  </div>
324
 
@@ -327,62 +357,18 @@ USER_TEMPLATE = """
327
 
328
  function applyTheme(themeParams) {
329
  const root = document.documentElement;
330
- root.style.setProperty('--druzhba-yellow', themeParams.button_color || '#ffc700');
331
- root.style.setProperty('--druzhba-black', themeParams.bg_color || '#121212');
332
- root.style.setProperty('--druzhba-text', themeParams.text_color || '#ffffff');
333
- root.style.setProperty('--druzhba-text-secondary', themeParams.hint_color || '#8a8a8e');
334
- root.style.setProperty('--druzhba-card-bg', themeParams.secondary_bg_color || '#1c1c1e');
335
- }
336
-
337
- function formatBonus(amount) {
338
- return parseFloat(amount).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
339
- }
340
-
341
- function updateUI(userData) {
342
- const greetingEl = document.getElementById('user-greeting');
343
- const balanceEl = document.getElementById('balance-amount');
344
- const transactionListEl = document.getElementById('transaction-list');
345
-
346
- const name = userData.first_name || userData.username || 'Гость';
347
- greetingEl.textContent = `Добро пожаловать, ${name}!`;
348
-
349
- balanceEl.classList.remove('loader');
350
- balanceEl.textContent = formatBonus(userData.balance || 0);
351
-
352
- transactionListEl.innerHTML = '';
353
-
354
- if (userData.transactions && userData.transactions.length > 0) {
355
- const sortedTransactions = userData.transactions.sort((a, b) => b.timestamp - a.timestamp);
356
- sortedTransactions.forEach(tx => {
357
- const item = document.createElement('li');
358
- item.className = 'transaction-item';
359
-
360
- const date = new Date(tx.timestamp * 1000);
361
- const formattedDate = date.toLocaleString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
362
-
363
- const sign = tx.type === 'accrual' ? '+' : '-';
364
- const amountClass = tx.type === 'accrual' ? 'accrual' : 'deduction';
365
-
366
- item.innerHTML = `
367
- <div class="transaction-details">
368
- <span class="transaction-description">${tx.description}</span>
369
- <span class="transaction-date">${formattedDate}</span>
370
- </div>
371
- <span class="transaction-amount ${amountClass}">
372
- ${sign} ${formatBonus(tx.amount)}
373
- </span>
374
- `;
375
- transactionListEl.appendChild(item);
376
- });
377
- } else {
378
- transactionListEl.innerHTML = '<li class="no-history">История операций пуста</li>';
379
- }
380
  }
381
 
382
  function setupTelegram() {
383
  if (!tg || !tg.initData) {
384
  console.error("Telegram WebApp script not loaded or initData is missing.");
385
- document.getElementById('user-greeting').textContent = 'Ошибка загрузки.';
386
  document.body.style.visibility = 'visible';
387
  return;
388
  }
@@ -390,31 +376,49 @@ USER_TEMPLATE = """
390
  tg.ready();
391
  tg.expand();
392
 
393
- applyTheme(tg.themeParams);
 
 
394
  tg.onEvent('themeChanged', () => applyTheme(tg.themeParams));
395
 
396
  fetch('/verify', {
397
  method: 'POST',
398
- headers: { 'Content-Type': 'application/json' },
 
 
 
399
  body: JSON.stringify({ initData: tg.initData }),
400
  })
401
  .then(response => response.json())
402
  .then(data => {
403
  if (data.status === 'ok' && data.verified) {
404
- updateUI(data.user);
405
  } else {
406
- throw new Error(data.message || 'Verification failed');
407
  }
408
  })
409
- .catch(error => {
410
- console.error('Error fetching user data:', error);
411
- document.getElementById('user-greeting').textContent = 'Не удалось загрузить данные.';
412
- });
 
 
 
 
413
 
414
  document.body.style.visibility = 'visible';
 
 
 
 
 
 
 
 
 
 
 
415
  }
416
-
417
- window.addEventListener('load', setupTelegram);
418
  </script>
419
  </body>
420
  </html>
@@ -429,280 +433,269 @@ ADMIN_TEMPLATE = """
429
  <title>Druzhba Admin</title>
430
  <link rel="preconnect" href="https://fonts.googleapis.com">
431
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
432
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
433
  <style>
434
  :root {
435
- --admin-bg: #f0f2f5; --admin-text: #333; --admin-card-bg: #fff; --admin-border: #e0e0e0;
436
- --admin-shadow: rgba(0, 0, 0, 0.08); --admin-primary: #1877f2; --admin-secondary: #65676b;
437
- --admin-success: #31a24c; --admin-danger: #e02c4b; --admin-yellow: #ffc700;
438
- --border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif;
439
- }
440
- body { font-family: var(--font-family); background-color: var(--admin-bg); margin: 0; padding: var(--padding); }
 
 
 
 
 
 
 
 
 
441
  .container { max-width: 1200px; margin: 0 auto; }
442
- h1 { text-align: center; color: var(--admin-secondary); margin-bottom: 1rem; }
443
- .controls-container {
444
- display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; align-items: center;
445
- background: var(--admin-card-bg); padding: 1rem; border-radius: var(--border-radius);
446
- box-shadow: 0 2px 8px var(--admin-shadow); margin-bottom: 2rem;
447
- }
448
- .search-box { flex-grow: 1; max-width: 400px; }
449
- .search-box input {
450
- width: 100%; padding: 12px 16px; font-size: 1em; border-radius: 8px;
451
- border: 1px solid var(--admin-border); transition: border-color 0.2s;
452
- }
453
- .search-box input:focus { border-color: var(--admin-primary); outline: none; }
454
- .user-grid {
455
- display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;
456
- }
457
- .user-card {
458
- background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: 1.5rem;
459
- box-shadow: 0 4px 12px var(--admin-shadow); border: 1px solid var(--admin-border);
460
- display: flex; flex-direction: column; transition: transform 0.2s, box-shadow 0.2s;
461
- }
462
- .user-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); }
463
  .user-info { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
464
- .user-info img { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; }
465
- .user-name-details { flex-grow: 1; }
466
- .user-name { font-weight: 600; font-size: 1.15em; margin-bottom: 0.2rem; }
467
- .user-username { color: var(--admin-secondary); font-size: 0.95em; }
468
- .user-balance {
469
- margin-top: 1rem; padding: 0.8rem; background-color: #f7f7f7; border-radius: 8px;
470
- text-align: center; border: 1px solid #eee;
471
- }
472
- .user-balance-label { font-size: 0.8em; color: var(--admin-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
473
- .user-balance-amount { font-size: 1.5em; font-weight: 700; color: var(--admin-primary); }
474
- .user-card-actions { margin-top: 1.5rem; }
475
- .btn {
476
- display: inline-block; width: 100%; text-align: center; padding: 12px; font-size: 1em; font-weight: 600;
477
- border: none; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;
478
- }
479
- .btn-manage { background-color: var(--admin-primary); color: #fff; }
480
- .btn-manage:hover { background-color: #166fe5; }
481
 
482
- .modal {
483
- display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%;
484
- overflow: auto; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
485
- }
486
- .modal-content {
487
- background-color: var(--admin-card-bg); margin: 5% auto; padding: 2rem;
488
- border-radius: var(--border-radius); width: 90%; max-width: 600px;
489
- box-shadow: 0 10px 40px rgba(0,0,0,0.2); position: relative;
490
- }
491
- .modal-close {
492
- color: #aaa; position: absolute; top: 1rem; right: 1.5rem; font-size: 32px;
493
- font-weight: bold; cursor: pointer; line-height: 1;
494
- }
495
- .modal-header { margin-bottom: 1.5rem; }
496
- .modal-title { font-size: 1.5em; font-weight: 700; }
497
- .modal-subtitle { color: var(--admin-secondary); }
498
- .modal-body { display: flex; flex-direction: column; gap: 2rem; }
499
- .form-section { padding: 1.5rem; border: 1px solid var(--admin-border); border-radius: 8px; }
500
- .form-section h3 { margin-top: 0; margin-bottom: 1rem; font-weight: 600; }
501
- .form-group { margin-bottom: 1rem; }
502
- .form-group label { display: block; font-weight: 500; margin-bottom: 0.5rem; }
503
- .form-group input { width: 100%; padding: 12px; font-size: 1em; border: 1px solid #ccc; border-radius: 8px; }
504
- .form-actions { text-align: right; }
505
- .btn-submit { background-color: var(--admin-success); color: #fff; padding: 12px 24px; }
506
- .history-list { list-style: none; padding: 0; max-height: 300px; overflow-y: auto; }
507
- .history-item { display: flex; justify-content: space-between; padding: 0.8rem 0; border-bottom: 1px solid #f0f0f0; }
508
- .history-item:last-child { border: none; }
509
- .history-desc { font-size: 0.95em; }
510
- .history-date { font-size: 0.8em; color: #888; }
511
- .history-amount { font-weight: 600; }
512
- .history-amount.accrual { color: var(--admin-success); }
513
- .history-amount.deduction { color: var(--admin-danger); }
514
- .status-message { margin-top: 1rem; padding: 1rem; border-radius: 8px; text-align: center; display: none; }
515
- .status-message.success { background-color: #eaf6ec; color: var(--admin-success); }
516
- .status-message.error { background-color: #fcebec; color: var(--admin-danger); }
517
  </style>
518
  </head>
519
  <body>
520
  <div class="container">
521
  <h1>Панель администратора Druzhba</h1>
522
-
523
- <div class="controls-container">
524
- <div class="search-box">
525
- <input type="text" id="searchInput" placeholder="Поиск по имени, username или ID...">
526
- </div>
527
  </div>
528
 
529
- <div class="user-grid" id="userGrid">
530
- {% for user in users|sort(attribute='visited_at', reverse=true) %}
531
- <div class="user-card" data-search-term="{{ user.first_name or '' }} {{ user.last_name or '' }} @{{ user.username or '' }} {{ user.id }}">
532
- <div class="user-info">
533
- <img src="{{ user.photo_url or 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="Avatar">
534
- <div class="user-name-details">
535
- <div class="user-name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div>
536
- {% if user.username %}<div class="user-username">@{{ user.username }}</div>{% endif %}
 
 
 
 
 
 
 
 
 
537
  </div>
538
  </div>
539
- <div class="user-balance">
540
- <div class="user-balance-label">Бонусный баланс</div>
541
- <div class="user-balance-amount" id="balance-{{ user.id }}">{{ "%.2f"|format(user.balance|float) }}</div>
542
- </div>
543
- <div class="user-card-actions">
544
- <button class="btn btn-manage"
545
- data-user-id="{{ user.id }}"
546
- data-user-name="{{ user.first_name or '' }} {{ user.last_name or '' }}"
547
- data-user-balance="{{ user.balance|float }}"
548
- data-user-transactions='{{ user.transactions|tojson|safe }}'>
549
- Управление бонусами
550
- </button>
551
- </div>
552
- </div>
553
- {% endfor %}
554
- </div>
555
  </div>
556
 
557
- <div id="manageModal" class="modal">
558
- <div class="modal-content">
559
- <span class="modal-close" id="modal-close-btn">×</span>
560
- <div class="modal-header">
561
- <h2 id="modal-title" class="modal-title">Управление бонусами</h2>
562
- <div id="modal-subtitle" class="modal-subtitle"></div>
 
 
 
 
 
 
 
 
563
  </div>
564
- <div class="modal-body">
565
- <section class="form-section">
566
- <h3>Новая операция</h3>
567
- <form id="transactionForm">
568
- <input type="hidden" id="modal-user-id">
569
- <div class="form-group">
570
- <label for="purchase_amount">Сумма покупки (для начисления 2%)</label>
571
- <input type="number" id="purchase_amount" name="purchase_amount" placeholder="Например: 5000" min="0" step="any">
572
- </div>
573
- <div class="form-group">
574
- <label for="spend_amount">Списать бонусов (текущий баланс: <span id="modal-current-balance">0.00</span>)</label>
575
- <input type="number" id="spend_amount" name="spend_amount" placeholder="Например: 150.50" min="0" step="any">
576
- </div>
577
- <div class="form-actions">
578
- <button type="submit" class="btn btn-submit">Провести операцию</button>
579
- </div>
580
- </form>
581
- <div id="statusMessage" class="status-message"></div>
582
- </section>
583
- <section>
584
- <h3>История операций</h3>
585
- <ul id="modal-history-list" class="history-list">
586
- <li>Нет данных</li>
587
- </ul>
588
- </section>
589
  </div>
590
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  </div>
592
 
593
  <script>
594
- document.addEventListener('DOMContentLoaded', () => {
595
- const searchInput = document.getElementById('searchInput');
596
- const userGrid = document.getElementById('userGrid');
597
- const userCards = userGrid.querySelectorAll('.user-card');
598
-
599
- searchInput.addEventListener('input', (e) => {
600
- const searchTerm = e.target.value.toLowerCase();
601
- userCards.forEach(card => {
602
- const cardSearchTerm = card.dataset.searchTerm.toLowerCase();
603
- if (cardSearchTerm.includes(searchTerm)) {
604
- card.style.display = '';
605
- } else {
606
- card.style.display = 'none';
607
- }
608
- });
609
  });
 
610
 
611
- const modal = document.getElementById('manageModal');
612
- const closeBtn = document.getElementById('modal-close-btn');
613
- const transactionForm = document.getElementById('transactionForm');
614
- const statusMessage = document.getElementById('statusMessage');
615
-
616
- document.querySelectorAll('.btn-manage').forEach(button => {
617
- button.addEventListener('click', (e) => {
618
- const userData = e.currentTarget.dataset;
619
- openModal(userData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  });
621
- });
622
-
623
- function openModal(userData) {
624
- document.getElementById('modal-title').textContent = `Бонусы: ${userData.userName}`;
625
- document.getElementById('modal-subtitle').textContent = `ID: ${userData.userId}`;
626
- document.getElementById('modal-user-id').value = userData.userId;
627
- document.getElementById('modal-current-balance').textContent = parseFloat(userData.userBalance).toFixed(2);
628
- transactionForm.reset();
629
- statusMessage.style.display = 'none';
630
-
631
- const transactions = JSON.parse(userData.userTransactions);
632
- updateHistoryList(transactions);
633
-
634
- modal.style.display = 'block';
635
  }
636
 
637
- function updateHistoryList(transactions) {
638
- const historyList = document.getElementById('modal-history-list');
639
- historyList.innerHTML = '';
640
- if (transactions && transactions.length > 0) {
641
- transactions.sort((a,b) => b.timestamp - a.timestamp).forEach(tx => {
642
- const item = document.createElement('li');
643
- item.className = 'history-item';
644
- const date = new Date(tx.timestamp * 1000).toLocaleString('ru-RU');
645
- const sign = tx.type === 'accrual' ? '+' : '-';
646
- item.innerHTML = `
647
- <div>
648
- <div class="history-desc">${tx.description}</div>
649
- <div class="history-date">${date}</div>
650
- </div>
651
- <div class="history-amount ${tx.type}">${sign} ${parseFloat(tx.amount).toFixed(2)}</div>
652
- `;
653
- historyList.appendChild(item);
654
- });
655
- } else {
656
- historyList.innerHTML = '<li>История пуста</li>';
657
- }
 
658
  }
659
 
660
- closeBtn.onclick = () => { modal.style.display = 'none'; };
661
- window.onclick = (event) => { if (event.target == modal) { modal.style.display = 'none'; } };
662
 
663
- transactionForm.addEventListener('submit', async (e) => {
664
- e.preventDefault();
665
- statusMessage.style.display = 'none';
666
-
667
- const userId = document.getElementById('modal-user-id').value;
668
- const purchaseAmount = document.getElementById('purchase_amount').value;
669
- const spendAmount = document.getElementById('spend_amount').value;
670
-
671
- try {
672
- const response = await fetch('/admin/transaction', {
673
- method: 'POST',
674
- headers: { 'Content-Type': 'application/json' },
675
- body: JSON.stringify({
676
- user_id: userId,
677
- purchase_amount: purchaseAmount || 0,
678
- spend_amount: spendAmount || 0
679
- })
680
- });
681
- const data = await response.json();
682
-
683
- if (response.ok) {
684
- statusMessage.textContent = data.message || 'Успешно!';
685
- statusMessage.className = 'status-message success';
686
-
687
- const updatedUser = data.user;
688
- document.getElementById(`balance-${userId}`).textContent = parseFloat(updatedUser.balance).toFixed(2);
689
- document.getElementById('modal-current-balance').textContent = parseFloat(updatedUser.balance).toFixed(2);
690
- const manageButton = document.querySelector(`.btn-manage[data-user-id='${userId}']`);
691
- manageButton.dataset.userBalance = updatedUser.balance;
692
- manageButton.dataset.userTransactions = JSON.stringify(updatedUser.transactions);
693
-
694
- updateHistoryList(updatedUser.transactions);
695
- transactionForm.reset();
696
- } else {
697
- throw new Error(data.message || 'Произошла ошибка');
698
- }
699
- } catch (error) {
700
- statusMessage.textContent = error.message;
701
- statusMessage.className = 'status-message error';
702
  }
703
- statusMessage.style.display = 'block';
704
- });
705
- });
 
 
 
 
 
 
 
 
706
  </script>
707
  </body>
708
  </html>
@@ -710,8 +703,17 @@ ADMIN_TEMPLATE = """
710
 
711
  @app.route('/')
712
  def index():
713
- theme_params = {}
714
- return render_template_string(USER_TEMPLATE, theme=theme_params)
 
 
 
 
 
 
 
 
 
715
 
716
  @app.route('/verify', methods=['POST'])
717
  def verify_data():
@@ -722,8 +724,8 @@ def verify_data():
722
  return jsonify({"status": "error", "message": "Missing initData"}), 400
723
 
724
  user_data_parsed, is_valid = verify_telegram_data(init_data_str)
725
- user_info_dict = {}
726
 
 
727
  if user_data_parsed and 'user' in user_data_parsed:
728
  try:
729
  user_json_str = unquote(user_data_parsed['user'][0])
@@ -733,39 +735,44 @@ def verify_data():
733
  user_info_dict = {}
734
 
735
  if is_valid:
736
- user_id = str(user_info_dict.get('id'))
737
  if user_id:
738
- with _data_lock:
739
- all_data = load_visitor_data()
740
- now = time.time()
741
-
742
- if user_id not in all_data:
743
- user_entry = {
744
- 'id': user_info_dict.get('id'),
745
- 'first_name': user_info_dict.get('first_name'),
746
- 'last_name': user_info_dict.get('last_name'),
747
- 'username': user_info_dict.get('username'),
748
- 'photo_url': user_info_dict.get('photo_url'),
749
- 'language_code': user_info_dict.get('language_code'),
750
- 'is_premium': user_info_dict.get('is_premium', False),
751
- 'visited_at': now,
752
- 'visited_at_str': datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S'),
753
- 'balance': 0.0,
754
- 'transactions': []
755
- }
756
- all_data[user_id] = user_entry
757
- else:
758
- all_data[user_id]['visited_at'] = now
759
- all_data[user_id]['visited_at_str'] = datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S')
760
- all_data[user_id]['first_name'] = user_info_dict.get('first_name')
761
- all_data[user_id]['last_name'] = user_info_dict.get('last_name')
762
- all_data[user_id]['username'] = user_info_dict.get('username')
763
- all_data[user_id]['photo_url'] = user_info_dict.get('photo_url')
764
-
765
- save_visitor_data({user_id: all_data[user_id]})
766
- current_user_data = all_data[user_id]
767
-
768
- return jsonify({"status": "ok", "verified": True, "user": current_user_data}), 200
 
 
 
 
 
769
  else:
770
  logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
771
  return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
@@ -774,101 +781,87 @@ def verify_data():
774
  logging.exception("Error in /verify endpoint")
775
  return jsonify({"status": "error", "message": "Internal server error"}), 500
776
 
 
777
  @app.route('/admin')
778
  def admin_panel():
779
  current_data = load_visitor_data()
780
  users_list = list(current_data.values())
781
  return render_template_string(ADMIN_TEMPLATE, users=users_list)
782
 
783
- @app.route('/admin/transaction', methods=['POST'])
784
- def admin_transaction():
785
- data = request.get_json()
786
- user_id = str(data.get('user_id'))
787
  try:
788
- purchase_amount = float(data.get('purchase_amount', 0)) if data.get('purchase_amount') else 0
789
- spend_amount = float(data.get('spend_amount', 0)) if data.get('spend_amount') else 0
790
- except (ValueError, TypeError):
791
- return jsonify({"status": "error", "message": "Неверный формат суммы."}), 400
792
 
793
- if not user_id or (purchase_amount <= 0 and spend_amount <= 0):
794
- return jsonify({"status": "error", "message": "Необходимо указать сумму покупки или сумму списания."}), 400
795
-
796
- with _data_lock:
797
  all_data = load_visitor_data()
798
- user = all_data.get(user_id)
799
 
800
- if not user:
801
- return jsonify({"status": "error", "message": "Пользователь не найден."}), 404
802
 
803
- if spend_amount > 0:
804
- if user.get('balance', 0) < spend_amount:
805
- return jsonify({"status": "error", "message": f"Недостаточно бонусов. Доступно: {user.get('balance', 0):.2f}"}), 400
806
-
807
- user['balance'] -= spend_amount
808
- spend_transaction = {
809
- "id": str(uuid.uuid4()),
810
- "timestamp": time.time(),
811
- "type": "deduction",
812
- "amount": spend_amount,
813
- "description": "Списание бонусов"
814
- }
815
- user.setdefault('transactions', []).append(spend_transaction)
816
 
817
- if purchase_amount > 0:
818
- accrual_amount = round(purchase_amount * 0.02, 2)
819
- user.setdefault('balance', 0.0)
820
- user['balance'] += accrual_amount
821
- accrual_transaction = {
822
- "id": str(uuid.uuid4()),
823
- "timestamp": time.time(),
 
 
 
 
 
824
  "type": "accrual",
825
  "amount": accrual_amount,
826
- "description": f"Бонусы за покупку на {purchase_amount:.2f}"
827
- }
828
- user.setdefault('transactions', []).append(accrual_transaction)
829
-
830
- save_visitor_data({user_id: user})
831
- updated_user = all_data[user_id]
832
-
833
- return jsonify({"status": "ok", "message": "Операция успешно проведена.", "user": updated_user})
 
 
 
 
 
 
 
834
 
835
- @app.route('/admin/download_data', methods=['POST'])
836
- def admin_trigger_download():
837
- success = download_data_from_hf()
838
- if success:
839
- return jsonify({"status": "ok", "message": "Скачивание данных с Hugging Face завершено."})
840
- else:
841
- return jsonify({"status": "error", "message": "Ошибка скачивания данных с Hugging Face."}), 500
842
 
843
- @app.route('/admin/upload_data', methods=['POST'])
844
- def admin_trigger_upload():
845
- if not HF_TOKEN_WRITE:
846
- return jsonify({"status": "error", "message": "HF_TOKEN_WRITE не настроен на сервере."}), 400
847
- upload_data_to_hf_async()
848
- return jsonify({"status": "ok", "message": "Загрузка данных на Hugging Face запущена."})
849
 
850
  if __name__ == '__main__':
851
- print("--- DRUZHBA BONUS APP SERVER ---")
852
  print(f"Server starting on http://{HOST}:{PORT}")
853
- print(f"Data file: {DATA_FILE}")
854
- print(f"Hugging Face Repo: {REPO_ID}")
855
-
856
  if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
857
  print("WARNING: Hugging Face token(s) not set. Backup/restore functionality will be limited.")
858
  else:
859
- print("Hugging Face tokens found. Attempting initial data download...")
860
  download_data_from_hf()
861
 
862
  load_visitor_data()
863
 
864
- print("WARNING: The /admin route is NOT protected by authentication.")
865
-
866
  if HF_TOKEN_WRITE:
867
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
868
  backup_thread.start()
869
  print("Periodic backup thread started (every hour).")
870
- else:
871
- print("Periodic backup disabled (HF_TOKEN_WRITE missing).")
872
 
873
  print("--- Server Ready ---")
874
  app.run(host=HOST, port=PORT, debug=False)
 
2
  # -*- coding: utf-8 -*-
3
 
4
  import os
5
+ from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for
6
  import hmac
7
  import hashlib
8
  import json
9
+ from urllib.parse import unquote, parse_qs, quote
10
  import time
11
  from datetime import datetime
12
  import logging
13
  import threading
14
  from huggingface_hub import HfApi, hf_hub_download
15
  from huggingface_hub.utils import RepositoryNotFoundError
 
16
 
17
  BOT_TOKEN = os.getenv("BOT_TOKEN", "7531615056:AAFL7Lp1kc-sAshoiM0tfez5-7wea26GXYU")
18
  HOST = '0.0.0.0'
19
  PORT = 7860
20
+ DATA_FILE = 'data.json'
21
 
22
  REPO_ID = "flpolprojects/druzhbabase"
23
+ HF_DATA_FILE_PATH = "data.json"
24
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
25
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
26
 
 
105
  try:
106
  api = HfApi()
107
  with _data_lock:
108
+ file_content_exists = os.path.getsize(DATA_FILE) > 0
109
+ if not file_content_exists:
110
  logging.warning(f"{DATA_FILE} is empty. Skipping upload.")
111
  return
112
+
113
  logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...")
114
  api.upload_file(
115
  path_or_fileobj=DATA_FILE,
 
153
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
154
 
155
  if calculated_hash == received_hash:
156
+ auth_date = int(parsed_data.get('auth_date', [0])[0])
157
+ current_time = int(time.time())
158
+ if current_time - auth_date > 86400:
159
+ logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}).")
160
  return parsed_data, True
161
  else:
162
  logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}")
 
165
  logging.error(f"Error verifying Telegram data: {e}")
166
  return None, False
167
 
168
+ TEMPLATE = """
169
  <!DOCTYPE html>
170
  <html lang="ru">
171
  <head>
172
  <meta charset="UTF-8">
173
  <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover">
174
+ <title>Druzhba Бонусы</title>
175
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
176
  <link rel="preconnect" href="https://fonts.googleapis.com">
177
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
178
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
179
  <style>
180
  :root {
181
+ --tg-theme-bg-color: #111111;
182
+ --tg-theme-text-color: #ffffff;
183
+ --tg-theme-hint-color: #aaaaaa;
184
+ --tg-theme-link-color: #FFC107;
185
+ --tg-theme-button-color: #FFC107;
186
+ --tg-theme-button-text-color: #000000;
187
+ --tg-theme-secondary-bg-color: #1e1e1e;
188
+
189
+ --brand-yellow: #FFC107;
190
+ --brand-black: #111111;
191
+ --card-bg: #222222;
192
+ --text-color: #ffffff;
193
+ --text-secondary-color: #aaaaaa;
194
+ --border-radius-m: 14px;
195
+ --border-radius-l: 18px;
196
+ --padding-m: 18px;
197
+ --padding-l: 28px;
198
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
199
+ --shadow-color: rgba(0, 0, 0, 0.4);
200
+ --shadow-light: 0 4px 15px var(--shadow-color);
201
+ --shadow-medium: 0 6px 25px var(--shadow-color);
202
  }
203
  * { box-sizing: border-box; margin: 0; padding: 0; }
204
+ html, body {
205
+ background-color: var(--brand-black);
206
+ font-family: var(--font-family);
207
+ color: var(--text-color);
 
208
  padding: var(--padding-m);
209
+ overscroll-behavior-y: none;
210
  -webkit-font-smoothing: antialiased;
211
  -moz-osx-font-smoothing: grayscale;
212
  visibility: hidden;
213
  min-height: 100vh;
214
  }
215
  .container {
216
+ max-width: 650px;
217
  margin: 0 auto;
218
  display: flex;
219
  flex-direction: column;
 
225
  }
226
  .logo {
227
  font-size: 2em;
228
+ font-weight: 700;
229
+ color: var(--brand-yellow);
230
  }
231
+ .welcome-text {
 
232
  font-size: 1.1em;
233
+ color: var(--text-secondary-color);
234
+ margin-top: 8px;
235
  }
236
+ .bonus-card {
237
+ background: linear-gradient(145deg, #2a2a2a, #1c1c1c);
238
+ border-radius: var(--border-radius-l);
239
  padding: var(--padding-l);
240
  text-align: center;
241
+ box-shadow: var(--shadow-medium);
242
+ border: 1px solid rgba(255, 193, 7, 0.2);
243
  }
244
+ .bonus-label {
245
+ font-size: 1.2em;
246
  font-weight: 500;
247
+ color: var(--text-secondary-color);
248
+ margin-bottom: 12px;
 
 
 
 
 
 
249
  }
250
+ .bonus-amount {
251
+ font-size: 3.5em;
252
+ font-weight: 700;
253
+ color: var(--brand-yellow);
254
+ letter-spacing: -1px;
255
  }
256
+ .bonus-amount-minor {
257
+ font-size: 0.5em;
258
+ opacity: 0.8;
 
259
  }
260
  .history-section {
261
+ background-color: var(--card-bg);
262
+ border-radius: var(--border-radius-l);
263
  padding: var(--padding-l);
264
+ box-shadow: var(--shadow-light);
265
+ border: 1px solid rgba(255, 255, 255, 0.08);
266
  }
267
  .history-title {
268
  font-size: 1.5em;
269
+ font-weight: 600;
270
  margin-bottom: var(--padding-m);
271
+ padding-bottom: var(--padding-m);
272
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
273
  }
274
+ .history-list {
275
  list-style: none;
276
  padding: 0;
277
+ margin: 0;
278
+ max-height: 400px;
279
+ overflow-y: auto;
280
  }
281
+ .history-item {
282
  display: flex;
283
  justify-content: space-between;
284
  align-items: center;
285
+ padding: 16px 4px;
286
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
287
  }
288
+ .history-item:last-child {
289
  border-bottom: none;
 
290
  }
291
+ .history-details {
292
  display: flex;
293
  flex-direction: column;
 
294
  }
295
+ .history-description {
 
296
  font-size: 1em;
297
+ font-weight: 500;
298
  }
299
+ .history-date {
300
  font-size: 0.85em;
301
+ color: var(--text-secondary-color);
302
+ margin-top: 4px;
303
  }
304
+ .history-amount {
 
305
  font-size: 1.2em;
306
+ font-weight: 600;
307
+ }
308
+ .history-amount.accrual {
309
+ color: #4CAF50;
310
+ }
311
+ .history-amount.deduction {
312
+ color: #F44336;
313
  }
 
 
314
  .no-history {
315
  text-align: center;
316
+ color: var(--text-secondary-color);
317
+ padding: 2rem 0;
318
  }
319
  </style>
320
  </head>
 
322
  <div class="container">
323
  <header class="header">
324
  <div class="logo">DRUZHBA</div>
325
+ <p id="greeting" class="welcome-text">Добро пожаловать!</p>
326
  </header>
327
 
328
+ <section class="bonus-card">
329
+ <p class="bonus-label">Ваши бонусы</p>
330
+ <p class="bonus-amount">{{ "%.2f"|format(user.bonuses|float) }}</p>
331
  </section>
332
+
333
  <section class="history-section">
334
  <h2 class="history-title">История операций</h2>
335
+ {% if user.history %}
336
+ <ul class="history-list">
337
+ {% for item in user.history|sort(attribute='date', reverse=true) %}
338
+ <li class="history-item">
339
+ <div class="history-details">
340
+ <span class="history-description">{{ item.description }}</span>
341
+ <span class="history-date">{{ item.date_str }}</span>
342
+ </div>
343
+ <span class="history-amount {{ 'accrual' if item.type == 'accrual' else 'deduction' }}">
344
+ {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
345
+ </span>
346
+ </li>
347
+ {% endfor %}
348
+ </ul>
349
+ {% else %}
350
+ <p class="no-history">Операций пока не было.</p>
351
+ {% endif %}
352
  </section>
353
  </div>
354
 
 
357
 
358
  function applyTheme(themeParams) {
359
  const root = document.documentElement;
360
+ if (themeParams.bg_color) root.style.setProperty('--tg-theme-bg-color', themeParams.bg_color);
361
+ if (themeParams.text_color) root.style.setProperty('--tg-theme-text-color', themeParams.text_color);
362
+ if (themeParams.hint_color) root.style.setProperty('--tg-theme-hint-color', themeParams.hint_color);
363
+ if (themeParams.link_color) root.style.setProperty('--tg-theme-link-color', themeParams.link_color);
364
+ if (themeParams.button_color) root.style.setProperty('--tg-theme-button-color', themeParams.button_color);
365
+ if (themeParams.button_text_color) root.style.setProperty('--tg-theme-button-text-color', themeParams.button_text_color);
366
+ if (themeParams.secondary_bg_color) root.style.setProperty('--tg-theme-secondary-bg-color', themeParams.secondary_bg_color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  }
368
 
369
  function setupTelegram() {
370
  if (!tg || !tg.initData) {
371
  console.error("Telegram WebApp script not loaded or initData is missing.");
 
372
  document.body.style.visibility = 'visible';
373
  return;
374
  }
 
376
  tg.ready();
377
  tg.expand();
378
 
379
+ if (tg.themeParams && Object.keys(tg.themeParams).length > 0) {
380
+ applyTheme(tg.themeParams);
381
+ }
382
  tg.onEvent('themeChanged', () => applyTheme(tg.themeParams));
383
 
384
  fetch('/verify', {
385
  method: 'POST',
386
+ headers: {
387
+ 'Content-Type': 'application/json',
388
+ 'Accept': 'application/json'
389
+ },
390
  body: JSON.stringify({ initData: tg.initData }),
391
  })
392
  .then(response => response.json())
393
  .then(data => {
394
  if (data.status === 'ok' && data.verified) {
395
+ console.log('Backend verification successful.');
396
  } else {
397
+ console.warn('Backend verification failed:', data.message);
398
  }
399
  })
400
+ .catch(error => console.error('Error sending initData for verification:', error));
401
+
402
+ const user = tg.initDataUnsafe?.user;
403
+ const greetingElement = document.getElementById('greeting');
404
+ if (user) {
405
+ const name = user.first_name || user.username || 'Гость';
406
+ greetingElement.textContent = `Добро пожаловать, ${name}! 👋`;
407
+ }
408
 
409
  document.body.style.visibility = 'visible';
410
+ }
411
+
412
+ if (window.Telegram && window.Telegram.WebApp) {
413
+ setupTelegram();
414
+ } else {
415
+ window.addEventListener('load', setupTelegram);
416
+ setTimeout(() => {
417
+ if (document.body.style.visibility !== 'visible') {
418
+ document.body.style.visibility = 'visible';
419
+ }
420
+ }, 3000);
421
  }
 
 
422
  </script>
423
  </body>
424
  </html>
 
433
  <title>Druzhba Admin</title>
434
  <link rel="preconnect" href="https://fonts.googleapis.com">
435
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
436
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
437
  <style>
438
  :root {
439
+ --admin-bg: #f8f9fa;
440
+ --admin-text: #212529;
441
+ --admin-card-bg: #ffffff;
442
+ --admin-border: #dee2e6;
443
+ --admin-shadow: rgba(0, 0, 0, 0.05);
444
+ --admin-primary: #FFC107;
445
+ --admin-primary-dark: #e0a800;
446
+ --admin-secondary: #6c757d;
447
+ --admin-success: #198754;
448
+ --admin-danger: #dc3545;
449
+ --border-radius: 12px;
450
+ --padding: 1.5rem;
451
+ --font-family: 'Inter', sans-serif;
452
+ }
453
+ body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; }
454
  .container { max-width: 1200px; margin: 0 auto; }
455
+ h1 { text-align: center; color: var(--admin-secondary); margin-bottom: var(--padding); font-weight: 600; }
456
+ .controls-bar { background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); margin-bottom: var(--padding); }
457
+ .controls-bar input[type="text"] { width: 100%; padding: 12px 15px; font-size: 1.1em; border-radius: 8px; border: 1px solid var(--admin-border); box-sizing: border-box; }
458
+ .user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--padding); margin-top: var(--padding); }
459
+ .user-card { background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: var(--padding); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; }
460
+ .user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  .user-info { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
462
+ .user-info img { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 3px solid var(--admin-border); background-color: #eee; }
463
+ .user-details .name { font-weight: 600; font-size: 1.2em; }
464
+ .user-details .username { color: var(--admin-secondary); font-size: 0.95em; }
465
+ .user-bonuses { text-align: center; margin-bottom: 1rem; }
466
+ .user-bonuses .label { font-size: 0.9em; color: var(--admin-secondary); }
467
+ .user-bonuses .amount { font-size: 1.8em; font-weight: 700; color: var(--admin-primary-dark); }
468
+ .user-actions .btn { display: block; width: 100%; padding: 10px; background-color: var(--admin-primary); color: #000; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s; }
469
+ .user-actions .btn:hover { background-color: var(--admin-primary-dark); }
470
+ .no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
 
 
 
 
 
 
 
 
471
 
472
+ .modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(5px); }
473
+ .modal-content { background-color: var(--admin-bg); margin: 10% auto; padding: var(--padding); border: 1px solid var(--admin-border); width: 90%; max-width: 600px; border-radius: var(--border-radius); position: relative; box-shadow: 0 8px 30px rgba(0,0,0,0.15); }
474
+ .modal-close { color: #aaa; position: absolute; top: 15px; right: 25px; font-size: 28px; font-weight: bold; cursor: pointer; }
475
+ .modal-header { padding-bottom: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--admin-border); }
476
+ .modal-header h2 { margin: 0; font-size: 1.5rem; }
477
+ .modal-header .username { font-size: 1rem; color: var(--admin-secondary); }
478
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: center; margin-bottom: 1.5rem; }
479
+ .form-group { display: flex; flex-direction: column; }
480
+ .form-group label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9em; }
481
+ .form-group input { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; }
482
+ .calculation-summary { background: #f0f0f0; padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
483
+ .summary-item { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.95em; }
484
+ .summary-item strong { font-weight: 600; }
485
+ .history-container { margin-top: 1.5rem; }
486
+ .history-container h3 { font-size: 1.2rem; margin-bottom: 1rem; }
487
+ .history-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 8px; }
488
+ .history-item { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); }
489
+ .history-item:last-child { border-bottom: none; }
490
+ .history-item .desc { font-size: 0.9em; }
491
+ .history-item .date { font-size: 0.8em; color: var(--admin-secondary); }
492
+ .history-item .amount.accrual { color: var(--admin-success); font-weight: 600; }
493
+ .history-item .amount.deduction { color: var(--admin-danger); font-weight: 600; }
494
+ .modal-footer button { padding: 12px 25px; font-size: 1.1em; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
495
+ .btn-submit { background-color: var(--admin-success); color: white; }
496
+ .status-message { margin-top: 1rem; text-align: center; font-weight: 500; }
 
 
 
 
 
 
 
 
 
 
497
  </style>
498
  </head>
499
  <body>
500
  <div class="container">
501
  <h1>Панель администратора Druzhba</h1>
502
+ <div class="controls-bar">
503
+ <input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username...">
 
 
 
504
  </div>
505
 
506
+ {% if users %}
507
+ <div class="user-grid" id="userGrid">
508
+ {% for user in users|sort(attribute='visited_at', reverse=true) %}
509
+ <div class="user-card" data-user-id="{{ user.id }}" data-search-term="{{ user.first_name|lower }} {{ user.last_name|lower }} {{ user.username|lower }} {{ user.id }}">
510
+ <div class="user-info">
511
+ <img src="{{ user.photo_url if user.photo_url else 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="User Avatar">
512
+ <div class="user-details">
513
+ <div class="name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div>
514
+ <div class="username">@{{ user.username or 'N/A' }}</div>
515
+ </div>
516
+ </div>
517
+ <div class="user-bonuses">
518
+ <div class="label">Текущие бонусы</div>
519
+ <div class="amount">{{ "%.2f"|format(user.bonuses|float) }}</div>
520
+ </div>
521
+ <div class="user-actions">
522
+ <button class="btn" onclick='openTransactionModal({{ user|tojson }})'>Управление бонусами</button>
523
  </div>
524
  </div>
525
+ {% endfor %}
526
+ </div>
527
+ {% else %}
528
+ <p class="no-users">Пользователей пока нет.</p>
529
+ {% endif %}
 
 
 
 
 
 
 
 
 
 
 
530
  </div>
531
 
532
+ <div id="transactionModal" class="modal">
533
+ <div class="modal-content">
534
+ <span class="modal-close" onclick="closeModal()">×</span>
535
+ <div class="modal-header">
536
+ <h2 id="modalUserName"></h2>
537
+ <div id="modalUserUsername" class="username"></div>
538
+ </div>
539
+
540
+ <input type="hidden" id="modalUserId">
541
+
542
+ <div class="form-row">
543
+ <div class="form-group">
544
+ <label for="purchaseAmount">Сумма покупки</label>
545
+ <input type="number" id="purchaseAmount" placeholder="Например, 1500" oninput="calculateBonuses()">
546
  </div>
547
+ <div class="form-group">
548
+ <label for="deductAmount">Списать бонусов</label>
549
+ <input type="number" id="deductAmount" placeholder="Например, 100" oninput="calculateBonuses()">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  </div>
551
  </div>
552
+
553
+ <div class="calculation-summary">
554
+ <div class="summary-item"><span>Текущий баланс:</span> <strong id="summaryCurrentBalance">0.00</strong></div>
555
+ <div class="summary-item"><span>Будет начислено (2%):</span> <strong id="summaryAccrual">+0.00</strong></div>
556
+ <div class="summary-item"><span>Будет списано:</span> <strong id="summaryDeduction">-0.00</strong></div>
557
+ <hr>
558
+ <div class="summary-item"><strong>Итоговый баланс:</strong> <strong id="summaryFinalBalance">0.00</strong></div>
559
+ </div>
560
+
561
+ <div class="history-container">
562
+ <h3>История операций</h3>
563
+ <ul id="modalHistoryList" class="history-list"></ul>
564
+ </div>
565
+
566
+ <div class="modal-footer">
567
+ <button class="btn-submit" onclick="submitTransaction()">Провести операцию</button>
568
+ <div id="modalStatus" class="status-message"></div>
569
+ </div>
570
+ </div>
571
  </div>
572
 
573
  <script>
574
+ const modal = document.getElementById('transactionModal');
575
+ const allUsers = {{ users|tojson }};
576
+ let currentUserData = null;
577
+
578
+ function searchUsers() {
579
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
580
+ const userCards = document.querySelectorAll('.user-card');
581
+ userCards.forEach(card => {
582
+ const cardSearchTerm = card.getAttribute('data-search-term');
583
+ if (cardSearchTerm.includes(searchTerm)) {
584
+ card.style.display = 'flex';
585
+ } else {
586
+ card.style.display = 'none';
587
+ }
 
588
  });
589
+ }
590
 
591
+ function openTransactionModal(userData) {
592
+ currentUserData = userData;
593
+ document.getElementById('modalUserId').value = userData.id;
594
+ document.getElementById('modalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`;
595
+ document.getElementById('modalUserUsername').textContent = `@${userData.username || 'N/A'}`;
596
+ document.getElementById('purchaseAmount').value = '';
597
+ document.getElementById('deductAmount').value = '';
598
+ document.getElementById('modalStatus').textContent = '';
599
+
600
+ const historyList = document.getElementById('modalHistoryList');
601
+ historyList.innerHTML = '';
602
+ if (userData.history && userData.history.length > 0) {
603
+ const sortedHistory = [...userData.history].sort((a, b) => new Date(b.date) - new Date(a.date));
604
+ sortedHistory.forEach(item => {
605
+ const li = document.createElement('li');
606
+ li.className = 'history-item';
607
+ const sign = item.type === 'accrual' ? '+' : '-';
608
+ const amountClass = item.type === 'accrual' ? 'accrual' : 'deduction';
609
+ li.innerHTML = `
610
+ <div>
611
+ <div class="desc">${item.description}</div>
612
+ <div class="date">${item.date_str}</div>
613
+ </div>
614
+ <div class="amount ${amountClass}">${sign}${parseFloat(item.amount).toFixed(2)}</div>
615
+ `;
616
+ historyList.appendChild(li);
617
  });
618
+ } else {
619
+ historyList.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет истории</li>';
 
 
 
 
 
 
 
 
 
 
 
 
620
  }
621
 
622
+ calculateBonuses();
623
+ modal.style.display = 'block';
624
+ }
625
+
626
+ function closeModal() {
627
+ modal.style.display = 'none';
628
+ currentUserData = null;
629
+ }
630
+
631
+ function calculateBonuses() {
632
+ if (!currentUserData) return;
633
+
634
+ const currentBalance = parseFloat(currentUserData.bonuses) || 0;
635
+ const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0;
636
+ const deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0;
637
+
638
+ const accrualAmount = purchaseAmount * 0.02;
639
+
640
+ let finalDeductAmount = deductAmount;
641
+ if (deductAmount > currentBalance) {
642
+ finalDeductAmount = currentBalance;
643
+ document.getElementById('deductAmount').value = finalDeductAmount;
644
  }
645
 
646
+ const finalBalance = currentBalance + accrualAmount - finalDeductAmount;
 
647
 
648
+ document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2);
649
+ document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`;
650
+ document.getElementById('summaryDeduction').textContent = `-${finalDeductAmount.toFixed(2)}`;
651
+ document.getElementById('summaryFinalBalance').textContent = finalBalance.toFixed(2);
652
+ }
653
+
654
+ async function submitTransaction() {
655
+ const statusEl = document.getElementById('modalStatus');
656
+ statusEl.style.color = 'var(--admin-secondary)';
657
+ statusEl.textContent = 'Обработка...';
658
+
659
+ const payload = {
660
+ user_id: document.getElementById('modalUserId').value,
661
+ purchase_amount: parseFloat(document.getElementById('purchaseAmount').value) || 0,
662
+ deduct_amount: parseFloat(document.getElementById('deductAmount').value) || 0,
663
+ };
664
+
665
+ if (payload.purchase_amount <= 0 && payload.deduct_amount <= 0) {
666
+ statusEl.style.color = 'var(--admin-danger)';
667
+ statusEl.textContent = 'Введите сумму покупки или сумму для списания.';
668
+ return;
669
+ }
670
+
671
+ try {
672
+ const response = await fetch('/admin/add_transaction', {
673
+ method: 'POST',
674
+ headers: { 'Content-Type': 'application/json' },
675
+ body: JSON.stringify(payload)
676
+ });
677
+ const result = await response.json();
678
+
679
+ if (response.ok) {
680
+ statusEl.style.color = 'var(--admin-success)';
681
+ statusEl.textContent = 'Операция успешно проведена!';
682
+ setTimeout(() => {
683
+ location.reload();
684
+ }, 1500);
685
+ } else {
686
+ throw new Error(result.message || 'Произошла ошибка');
687
  }
688
+ } catch (error) {
689
+ statusEl.style.color = 'var(--admin-danger)';
690
+ statusEl.textContent = `Ошибка: ${error.message}`;
691
+ }
692
+ }
693
+
694
+ window.onclick = function(event) {
695
+ if (event.target == modal) {
696
+ closeModal();
697
+ }
698
+ }
699
  </script>
700
  </body>
701
  </html>
 
703
 
704
  @app.route('/')
705
  def index():
706
+ user_id_str = request.args.get('user_id_for_test')
707
+
708
+ current_data = load_visitor_data()
709
+ user_data = {}
710
+
711
+ if user_id_str and user_id_str in current_data:
712
+ user_data = current_data[user_id_str]
713
+ else:
714
+ user_data = { "bonuses": 0, "history": [] }
715
+
716
+ return render_template_string(TEMPLATE, user=user_data)
717
 
718
  @app.route('/verify', methods=['POST'])
719
  def verify_data():
 
724
  return jsonify({"status": "error", "message": "Missing initData"}), 400
725
 
726
  user_data_parsed, is_valid = verify_telegram_data(init_data_str)
 
727
 
728
+ user_info_dict = {}
729
  if user_data_parsed and 'user' in user_data_parsed:
730
  try:
731
  user_json_str = unquote(user_data_parsed['user'][0])
 
735
  user_info_dict = {}
736
 
737
  if is_valid:
738
+ user_id = user_info_dict.get('id')
739
  if user_id:
740
+ now = datetime.now()
741
+ user_id_str = str(user_id)
742
+
743
+ all_data = load_visitor_data()
744
+
745
+ if user_id_str in all_data:
746
+ user_entry = all_data[user_id_str]
747
+ user_entry.update({
748
+ 'first_name': user_info_dict.get('first_name'),
749
+ 'last_name': user_info_dict.get('last_name'),
750
+ 'username': user_info_dict.get('username'),
751
+ 'photo_url': user_info_dict.get('photo_url'),
752
+ 'language_code': user_info_dict.get('language_code'),
753
+ 'visited_at': now.timestamp(),
754
+ 'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S')
755
+ })
756
+ else:
757
+ user_entry = {
758
+ 'id': user_id,
759
+ 'first_name': user_info_dict.get('first_name'),
760
+ 'last_name': user_info_dict.get('last_name'),
761
+ 'username': user_info_dict.get('username'),
762
+ 'photo_url': user_info_dict.get('photo_url'),
763
+ 'language_code': user_info_dict.get('language_code'),
764
+ 'is_premium': user_info_dict.get('is_premium', False),
765
+ 'visited_at': now.timestamp(),
766
+ 'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S'),
767
+ 'bonuses': 0,
768
+ 'history': []
769
+ }
770
+
771
+ save_visitor_data({user_id_str: user_entry})
772
+
773
+ return redirect(url_for('index', user_id_for_test=user_id_str))
774
+
775
+ return jsonify({"status": "ok", "verified": True, "user": user_info_dict}), 200
776
  else:
777
  logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
778
  return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
 
781
  logging.exception("Error in /verify endpoint")
782
  return jsonify({"status": "error", "message": "Internal server error"}), 500
783
 
784
+
785
  @app.route('/admin')
786
  def admin_panel():
787
  current_data = load_visitor_data()
788
  users_list = list(current_data.values())
789
  return render_template_string(ADMIN_TEMPLATE, users=users_list)
790
 
791
+ @app.route('/admin/add_transaction', methods=['POST'])
792
+ def add_transaction():
 
 
793
  try:
794
+ data = request.get_json()
795
+ user_id = data.get('user_id')
796
+ purchase_amount = float(data.get('purchase_amount', 0))
797
+ deduct_amount = float(data.get('deduct_amount', 0))
798
 
799
+ if not user_id:
800
+ return jsonify({"status": "error", "message": "User ID is required"}), 400
801
+
802
+ user_id_str = str(user_id)
803
  all_data = load_visitor_data()
 
804
 
805
+ if user_id_str not in all_data:
806
+ return jsonify({"status": "error", "message": "User not found"}), 404
807
 
808
+ user = all_data[user_id_str]
809
+ now = datetime.now()
810
+ now_str = now.strftime('%Y-%m-%d %H:%M:%S')
 
 
 
 
 
 
 
 
 
 
811
 
812
+ accrual_amount = purchase_amount * 0.02
813
+
814
+ if deduct_amount > user.get('bonuses', 0):
815
+ return jsonify({"status": "error", "message": "Not enough bonuses to deduct"}), 400
816
+
817
+ user['bonuses'] = user.get('bonuses', 0) + accrual_amount - deduct_amount
818
+
819
+ if 'history' not in user or not isinstance(user['history'], list):
820
+ user['history'] = []
821
+
822
+ if accrual_amount > 0:
823
+ user['history'].append({
824
  "type": "accrual",
825
  "amount": accrual_amount,
826
+ "description": f"Начисление с покупки {purchase_amount}",
827
+ "date": now.isoformat(),
828
+ "date_str": now_str
829
+ })
830
+
831
+ if deduct_amount > 0:
832
+ user['history'].append({
833
+ "type": "deduction",
834
+ "amount": deduct_amount,
835
+ "description": "Списание бонусов",
836
+ "date": now.isoformat(),
837
+ "date_str": now_str
838
+ })
839
+
840
+ save_visitor_data({user_id_str: user})
841
 
842
+ return jsonify({"status": "ok", "message": "Transaction successful", "new_balance": user['bonuses']}), 200
 
 
 
 
 
 
843
 
844
+ except Exception as e:
845
+ logging.exception("Error in /admin/add_transaction endpoint")
846
+ return jsonify({"status": "error", "message": str(e)}), 500
 
 
 
847
 
848
  if __name__ == '__main__':
849
+ print("--- DRUZHBA BONUS SYSTEM SERVER ---")
850
  print(f"Server starting on http://{HOST}:{PORT}")
 
 
 
851
  if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
852
  print("WARNING: Hugging Face token(s) not set. Backup/restore functionality will be limited.")
853
  else:
854
+ print("Attempting initial data download from Hugging Face...")
855
  download_data_from_hf()
856
 
857
  load_visitor_data()
858
 
859
+ print("WARNING: The /admin route is NOT protected. Implement proper authentication for production.")
860
+
861
  if HF_TOKEN_WRITE:
862
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
863
  backup_thread.start()
864
  print("Periodic backup thread started (every hour).")
 
 
865
 
866
  print("--- Server Ready ---")
867
  app.run(host=HOST, port=PORT, debug=False)